Commit 7175a9c3 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Phil Hughes

Move all translations string to constant file

- add constants
- bind constants in the list
parent a661d092
......@@ -53,7 +53,6 @@ export default {
:primary-button-text="alertConfiguration.primaryButton"
:primary-button-link="config.settingsPath"
:title="alertConfiguration.title"
class="my-2"
>
<gl-sprintf :message="alertConfiguration.message">
<template #days>
......
import { s__ } from '~/locale';
// List page
export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
export const CONNECTION_ERROR_MESSAGE = s__(
`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`,
);
export const LIST_INTRO_TEXT = s__(
`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
);
export const LIST_DELETE_BUTTON_DISABLED = s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
);
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.',
);
export const ROW_SCHEDULED_FOR_DELETION = s__(
`ContainerRegistry|This image repository is scheduled for deletion`,
);
export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the packages list.',
'ContainerRegistry|Something went wrong while fetching the repository list.',
);
export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the tags list.',
);
export const DELETE_IMAGE_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while deleting the image.',
'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.',
);
export const DELETE_IMAGE_SUCCESS_MESSAGE = s__('ContainerRegistry|Image deleted successfully');
export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__(
`ContainerRegistry|There was an error during the deletion of this image repository, please try again.`,
);
export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
'ContainerRegistry|%{title} was successfully scheduled for deletion',
);
// Image details page
export const DELETE_TAG_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while deleting the tag.',
);
......@@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
// Expiration policies
export const EXPIRATION_POLICY_ALERT_TITLE = s__(
'ContainerRegistry|Retention policy has been Enabled',
);
......@@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__(
'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}',
);
// Quick Start
export const QUICK_START = s__('ContainerRegistry|Quick Start');
export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
......@@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
// Image state
export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
......@@ -9,16 +9,28 @@ import {
GlModal,
GlSprintf,
GlLink,
GlAlert,
GlSkeletonLoader,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ProjectEmptyState from '../components/project_empty_state.vue';
import GroupEmptyState from '../components/group_empty_state.vue';
import ProjectPolicyAlert from '../components/project_policy_alert.vue';
import QuickstartDropdown from '../components/quickstart_dropdown.vue';
import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE } from '../constants';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
LIST_INTRO_TEXT,
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
REMOVE_REPOSITORY_MODAL_TEXT,
ROW_SCHEDULED_FOR_DELETION,
} from '../constants';
export default {
name: 'RegistryListApp',
......@@ -35,6 +47,7 @@ export default {
GlModal,
GlSprintf,
GlLink,
GlAlert,
GlSkeletonLoader,
},
directives: {
......@@ -47,25 +60,20 @@ export default {
height: 40,
},
i18n: {
containerRegistryTitle: s__('ContainerRegistry|Container Registry'),
connectionErrorTitle: s__('ContainerRegistry|Docker connection error'),
connectionErrorMessage: s__(
`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`,
),
introText: s__(
`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
),
deleteButtonDisabled: s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
),
removeRepositoryLabel: s__('ContainerRegistry|Remove repository'),
removeRepositoryModalText: s__(
'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
),
containerRegistryTitle: CONTAINER_REGISTRY_TITLE,
connectionErrorTitle: CONNECTION_ERROR_TITLE,
connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
introText: LIST_INTRO_TEXT,
deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED,
removeRepositoryLabel: REMOVE_REPOSITORY_LABEL,
removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT,
rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION,
asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
},
data() {
return {
itemToDelete: {},
deleteAlertType: null,
};
},
computed: {
......@@ -86,43 +94,61 @@ export default {
showQuickStartDropdown() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
showDeleteAlert() {
return this.deleteAlertType && this.itemToDelete?.path;
},
deleteImageAlertMessage() {
return this.deleteAlertType === 'success'
? DELETE_IMAGE_SUCCESS_MESSAGE
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']),
deleteImage(item) {
// This event is already tracked in the system and so the name must be kept to aggregate the data
this.track('click_button');
this.itemToDelete = item;
this.$refs.deleteModal.show();
},
handleDeleteImage() {
this.track('confirm_delete');
return this.requestDeleteImage(this.itemToDelete.destroy_path)
.then(() =>
this.$toast.show(DELETE_IMAGE_SUCCESS_MESSAGE, {
type: 'success',
}),
)
.catch(() =>
this.$toast.show(DELETE_IMAGE_ERROR_MESSAGE, {
type: 'error',
}),
)
.finally(() => {
this.itemToDelete = {};
return this.requestDeleteImage(this.itemToDelete)
.then(() => {
this.deleteAlertType = 'success';
})
.catch(() => {
this.deleteAlertType = 'danger';
});
},
encodeListItem(item) {
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
return window.btoa(params);
},
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
},
},
};
</script>
<template>
<div class="w-100 slide-enter-from-element">
<project-policy-alert v-if="!config.isGroupPage" />
<gl-alert
v-if="showDeleteAlert"
:variant="deleteAlertType"
class="mt-2"
dismissible
@dismiss="dismissDeleteAlert"
>
<gl-sprintf :message="deleteImageAlertMessage">
<template #title>
{{ itemToDelete.path }}
</template>
</gl-sprintf>
</gl-alert>
<project-policy-alert v-if="!config.isGroupPage" class="mt-2" />
<gl-empty-state
v-if="config.characterError"
......@@ -178,41 +204,57 @@ export default {
v-for="(listItem, index) in images"
:key="index"
ref="rowItem"
:class="{ 'border-top': index === 0 }"
class="d-flex justify-content-between align-items-center py-2 border-bottom"
v-gl-tooltip="{
placement: 'left',
disabled: !listItem.deleting,
title: $options.i18n.rowScheduledForDeletion,
}"
>
<div>
<router-link
ref="detailsLink"
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
>
{{ listItem.path }}
</router-link>
<clipboard-button
v-if="listItem.location"
ref="clipboardButton"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
<div
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="$options.i18n.deleteButtonDisabled"
class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom"
:class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
>
<gl-deprecated-button
ref="deleteImageButton"
v-gl-tooltip
:disabled="!listItem.destroy_path"
:title="$options.i18n.removeRepositoryLabel"
:aria-label="$options.i18n.removeRepositoryLabel"
class="btn-inverted"
variant="danger"
@click="deleteImage(listItem)"
<div class="d-felx align-items-center">
<router-link
ref="detailsLink"
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
>
{{ listItem.path }}
</router-link>
<clipboard-button
v-if="listItem.location"
ref="clipboardButton"
:disabled="listItem.deleting"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
<gl-icon
v-if="listItem.failedDelete"
v-gl-tooltip
:title="$options.i18n.asyncDeleteErrorMessage"
name="warning"
class="text-warning align-middle"
/>
</div>
<div
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="$options.i18n.deleteButtonDisabled"
>
<gl-icon name="remove" />
</gl-deprecated-button>
<gl-deprecated-button
ref="deleteImageButton"
v-gl-tooltip
:disabled="!listItem.destroy_path || listItem.deleting"
:title="$options.i18n.removeRepositoryLabel"
:aria-label="$options.i18n.removeRepositoryLabel"
class="btn-inverted"
variant="danger"
@click="deleteImage(listItem)"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</div>
</div>
</div>
<gl-pagination
......
......@@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params })
});
};
export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => {
export const requestDeleteImage = ({ commit }, image) => {
commit(types.SET_MAIN_LOADING, true);
return axios
.delete(destroyPath)
.delete(image.destroy_path)
.then(() => {
dispatch('setShowGarbageCollectionTip', true);
dispatch('requestImagesList', { pagination: state.pagination });
commit(types.UPDATE_IMAGE, { ...image, deleting: true });
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
......
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
export const UPDATE_IMAGE = 'UPDATE_IMAGE';
export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION';
......
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants';
export default {
[types.SET_INITIAL_STATE](state, config) {
......@@ -12,7 +13,17 @@ export default {
},
[types.SET_IMAGES_LIST_SUCCESS](state, images) {
state.images = images;
state.images = images.map(i => ({
...i,
status: undefined,
deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS,
failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS,
}));
},
[types.UPDATE_IMAGE](state, image) {
const index = state.images.findIndex(i => i.id === image.id);
state.images.splice(index, 1, { ...image });
},
[types.SET_TAGS_LIST_SUCCESS](state, tags) {
......
---
title: Enable async delete in container repository list
merge_request: 29175
author:
type: changed
......@@ -5459,6 +5459,9 @@ msgstr ""
msgid "ContainerRegistry|%{imageName} tags"
msgstr ""
msgid "ContainerRegistry|%{title} was successfully scheduled for deletion"
msgstr ""
msgid "ContainerRegistry|Automatically remove extra images that aren't designed to be kept."
msgstr ""
......@@ -5513,9 +5516,6 @@ msgstr ""
msgid "ContainerRegistry|Image ID"
msgstr ""
msgid "ContainerRegistry|Image deleted successfully"
msgstr ""
msgid "ContainerRegistry|Keep and protect the images that matter most."
msgstr ""
......@@ -5551,9 +5551,6 @@ msgstr[1] ""
msgid "ContainerRegistry|Retention policy has been Enabled"
msgstr ""
msgid "ContainerRegistry|Something went wrong while deleting the image."
msgstr ""
msgid "ContainerRegistry|Something went wrong while deleting the tag."
msgstr ""
......@@ -5563,12 +5560,15 @@ msgstr ""
msgid "ContainerRegistry|Something went wrong while fetching the expiration policy."
msgstr ""
msgid "ContainerRegistry|Something went wrong while fetching the packages list."
msgid "ContainerRegistry|Something went wrong while fetching the repository list."
msgstr ""
msgid "ContainerRegistry|Something went wrong while fetching the tags list."
msgstr ""
msgid "ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again."
msgstr ""
msgid "ContainerRegistry|Something went wrong while updating the expiration policy."
msgstr ""
......@@ -5605,12 +5605,18 @@ msgstr ""
msgid "ContainerRegistry|There are no container images stored for this project"
msgstr ""
msgid "ContainerRegistry|There was an error during the deletion of this image repository, please try again."
msgstr ""
msgid "ContainerRegistry|This Registry contains deleted image tag data. Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage."
msgstr ""
msgid "ContainerRegistry|This image has no active tags"
msgstr ""
msgid "ContainerRegistry|This image repository is scheduled for deletion"
msgstr ""
msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
......
import VueRouter from 'vue-router';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlPagination, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import { GlPagination, GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue';
import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue';
import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue';
import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue';
import ProjectPolicyAlert from '~/registry/explorer/components/project_policy_alert.vue';
import store from '~/registry/explorer/stores/';
import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
import {
......@@ -35,6 +36,8 @@ describe('List Page', () => {
const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown);
const findProjectEmptyState = () => wrapper.find(ProjectEmptyState);
const findGroupEmptyState = () => wrapper.find(GroupEmptyState);
const findProjectPolicyAlert = () => wrapper.find(ProjectPolicyAlert);
const findDeleteAlert = () => wrapper.find(GlAlert);
beforeEach(() => {
wrapper = shallowMount(component, {
......@@ -57,6 +60,18 @@ describe('List Page', () => {
wrapper.destroy();
});
describe('Expiration policy notification', () => {
it('shows up on project page', () => {
expect(findProjectPolicyAlert().exists()).toBe(true);
});
it('does show up on group page', () => {
store.dispatch('setInitialState', { isGroupPage: true });
return wrapper.vm.$nextTick().then(() => {
expect(findProjectPolicyAlert().exists()).toBe(false);
});
});
});
describe('connection error', () => {
const config = {
characterError: true,
......@@ -179,32 +194,38 @@ describe('List Page', () => {
it('should call deleteItem when confirming deletion', () => {
dispatchSpy.mockResolvedValue();
const itemToDelete = wrapper.vm.images[0];
wrapper.setData({ itemToDelete });
findDeleteBtn().vm.$emit('click');
expect(wrapper.vm.itemToDelete).not.toEqual({});
findDeleteModal().vm.$emit('ok');
expect(store.dispatch).toHaveBeenCalledWith(
'requestDeleteImage',
itemToDelete.destroy_path,
wrapper.vm.itemToDelete,
);
});
it('should show a success toast when delete request is successful', () => {
it('should show a success alert when delete request is successful', () => {
dispatchSpy.mockResolvedValue();
findDeleteBtn().vm.$emit('click');
expect(wrapper.vm.itemToDelete).not.toEqual({});
return wrapper.vm.handleDeleteImage().then(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_IMAGE_SUCCESS_MESSAGE, {
type: 'success',
});
expect(wrapper.vm.itemToDelete).toEqual({});
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
});
it('should show a error toast when delete request fails', () => {
it('should show an error alert when delete request fails', () => {
dispatchSpy.mockRejectedValue();
findDeleteBtn().vm.$emit('click');
expect(wrapper.vm.itemToDelete).not.toEqual({});
return wrapper.vm.handleDeleteImage().then(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(DELETE_IMAGE_ERROR_MESSAGE, {
type: 'error',
});
expect(wrapper.vm.itemToDelete).toEqual({});
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
});
});
......
......@@ -279,39 +279,32 @@ describe('Actions RegistryExplorer Store', () => {
});
describe('request delete single image', () => {
const deletePath = 'delete/path';
const image = {
destroy_path: 'delete/path',
};
it('successfully performs the delete request', done => {
mock.onDelete(deletePath).replyOnce(200);
mock.onDelete(image.destroy_path).replyOnce(200);
testAction(
actions.requestDeleteImage,
deletePath,
{
pagination: {},
},
image,
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.UPDATE_IMAGE, payload: { ...image, deleting: true } },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[
{
type: 'setShowGarbageCollectionTip',
payload: true,
},
{
type: 'requestImagesList',
payload: { pagination: {} },
},
],
[],
done,
);
});
it('should turn off loading on error', done => {
mock.onDelete(deletePath).replyOnce(400);
mock.onDelete(image.destroy_path).replyOnce(400);
testAction(
actions.requestDeleteImage,
deletePath,
image,
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
......
......@@ -28,14 +28,32 @@ describe('Mutations Registry Explorer Store', () => {
describe('SET_IMAGES_LIST_SUCCESS', () => {
it('should set the images list', () => {
const images = [1, 2, 3];
const expectedState = { ...mockState, images };
const images = [{ name: 'foo' }, { name: 'bar' }];
const defaultStatus = { deleting: false, failedDelete: false };
const expectedState = {
...mockState,
images: [{ name: 'foo', ...defaultStatus }, { name: 'bar', ...defaultStatus }],
};
mutations[types.SET_IMAGES_LIST_SUCCESS](mockState, images);
expect(mockState).toEqual(expectedState);
});
});
describe('UPDATE_IMAGE', () => {
it('should update an image', () => {
mockState.images = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }];
const payload = { id: 1, name: 'baz' };
const expectedState = {
...mockState,
images: [payload, { id: 2, name: 'bar' }],
};
mutations[types.UPDATE_IMAGE](mockState, payload);
expect(mockState).toEqual(expectedState);
});
});
describe('SET_TAGS_LIST_SUCCESS', () => {
it('should set the tags list', () => {
const tags = [1, 2, 3];
......
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