Commit 7af59bce authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Kushal Pandya

Add warning icon and tooltip on errored packages

parent 3c832e79
<script>
import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
import { s__ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS } from '../constants';
import { getPackageTypeLabel } from '../utils';
import PackagePath from './package_path.vue';
import PackageTags from './package_tags.vue';
......@@ -70,22 +72,45 @@ export default {
hasProjectLink() {
return Boolean(this.packageEntity.project_path);
},
showWarningIcon() {
return this.packageEntity.status === PACKAGE_ERROR_STATUS;
},
disabledRow() {
return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS;
},
disabledDeleteButton() {
return this.disabledRow || !this.packageEntity._links.delete_api_path;
},
},
i18n: {
erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'),
},
};
</script>
<template>
<list-item data-qa-selector="package_row">
<list-item data-qa-selector="package_row" :disabled="disabledRow">
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
<gl-link
:href="packageLink"
class="gl-text-body gl-min-w-0"
data-qa-selector="package_link"
:disabled="disabledRow"
>
<gl-truncate :text="packageEntity.name" />
</gl-link>
<gl-button
v-if="showWarningIcon"
v-gl-tooltip="{ title: $options.i18n.erroredPackageText }"
class="gl-hover-bg-transparent!"
icon="warning"
category="tertiary"
data-testid="warning-icon"
:aria-label="__('Warning')"
/>
<package-tags
v-if="packageEntity.tags && packageEntity.tags.length"
class="gl-ml-3"
......@@ -109,7 +134,11 @@ export default {
{{ packageType }}
</component>
<package-path v-if="hasProjectLink" :path="packageEntity.project_path" />
<package-path
v-if="hasProjectLink"
:path="packageEntity.project_path"
:disabled="disabledRow"
/>
</div>
</template>
......@@ -137,7 +166,7 @@ export default {
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"
:disabled="!packageEntity._links.delete_api_path"
:disabled="disabledDeleteButton"
@click="$emit('packageToDelete', packageEntity)"
/>
</template>
......
......@@ -16,6 +16,11 @@ export default {
type: String,
required: true,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
pathPieces() {
......@@ -45,7 +50,12 @@ export default {
<div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center">
<gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" />
<gl-link data-testid="root-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${rootLink}`">
<gl-link
data-testid="root-link"
class="gl-text-gray-500 gl-min-w-0"
:href="`/${rootLink}`"
:disabled="disabled"
>
{{ root }}
</gl-link>
......@@ -63,7 +73,12 @@ export default {
<gl-icon data-testid="ellipsis-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" />
</template>
<gl-link data-testid="leaf-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${path}`">
<gl-link
data-testid="leaf-link"
class="gl-text-gray-500 gl-min-w-0"
:href="`/${path}`"
:disabled="disabled"
>
{{ leaf }}
</gl-link>
</template>
......
......@@ -26,3 +26,8 @@ export const TrackingCategories = {
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_ERROR_MESSAGE = __('Something went wrong while deleting the package.');
export const PACKAGE_ERROR_STATUS = 'error';
export const PACKAGE_DEFAULT_STATUS = 'default';
export const PACKAGE_HIDDEN_STATUS = 'hidden';
export const PACKAGE_PROCESSING_STATUS = 'processing';
......@@ -50,6 +50,11 @@ export default {
default: false,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
},
i18n: {
REMOVE_TAG_BUTTON_TITLE,
......@@ -92,19 +97,25 @@ export default {
tagLocation() {
return this.tag.path?.replace(`:${this.tag.name}`, '');
},
invalidTag() {
isInvalidTag() {
return !this.tag.digest;
},
isCheckboxDisabled() {
return this.isInvalidTag || this.disabled;
},
isDeleteDisabled() {
return this.isInvalidTag || this.disabled || !this.tag.canDelete;
},
},
};
</script>
<template>
<list-item v-bind="$attrs" :selected="selected">
<list-item v-bind="$attrs" :selected="selected" :disabled="disabled">
<template #left-action>
<gl-form-checkbox
v-if="tag.canDelete"
:disabled="invalidTag"
:disabled="isCheckboxDisabled"
class="gl-m-0"
:checked="selected"
@change="$emit('select')"
......@@ -126,10 +137,11 @@ export default {
:title="tag.location"
:text="tag.location"
category="tertiary"
:disabled="disabled"
/>
<gl-icon
v-if="invalidTag"
v-if="isInvalidTag"
v-gl-tooltip="{ title: $options.i18n.MISSING_MANIFEST_WARNING_TOOLTIP }"
name="warning"
class="gl-text-orange-500 gl-mb-2 gl-ml-2"
......@@ -162,7 +174,7 @@ export default {
</template>
<template #right-action>
<delete-button
:disabled="!tag.canDelete || invalidTag"
:disabled="isDeleteDisabled"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
:tooltip-disabled="tag.canDelete"
......@@ -172,7 +184,7 @@ export default {
/>
</template>
<template v-if="!invalidTag" #details-published>
<template v-if="!isInvalidTag" #details-published>
<details-row icon="clock" data-testid="published-date-detail">
<gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT">
<template #repositoryPath>
......@@ -187,7 +199,7 @@ export default {
</gl-sprintf>
</details-row>
</template>
<template v-if="!invalidTag" #details-manifest-digest>
<template v-if="!isInvalidTag" #details-manifest-digest>
<details-row icon="log" data-testid="manifest-detail">
<gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST">
<template #digest>
......@@ -200,10 +212,11 @@ export default {
:text="tag.digest"
category="tertiary"
size="small"
:disabled="disabled"
/>
</details-row>
</template>
<template v-if="!invalidTag" #details-configuration-digest>
<template v-if="!isInvalidTag" #details-configuration-digest>
<details-row icon="cloud-gear" data-testid="configuration-detail">
<gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST">
<template #digest>
......@@ -216,6 +229,7 @@ export default {
:text="formattedRevision"
category="tertiary"
size="small"
:disabled="disabled"
/>
</details-row>
</template>
......
......@@ -78,6 +78,9 @@ export default {
imageName() {
return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`;
},
routerLinkEvent() {
return this.deleting ? '' : 'click';
},
},
};
</script>
......@@ -97,6 +100,7 @@ export default {
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
data-qa-selector="registry_image_content"
:event="routerLinkEvent"
:to="{ name: 'details', params: { id } }"
>
{{ imageName }}
......
......@@ -32,7 +32,7 @@ export default {
return {
'gl-border-t-transparent': !this.first && !this.selected,
'gl-border-t-gray-100': this.first && !this.selected,
'disabled-content': this.disabled,
'gl-opacity-5': this.disabled,
'gl-border-b-gray-100': !this.selected,
'gl-bg-blue-50 gl-border-blue-200': this.selected,
};
......
......@@ -23359,6 +23359,9 @@ msgstr ""
msgid "PackageRegistry|Install package version"
msgstr ""
msgid "PackageRegistry|Invalid Package: failed metadata extraction"
msgstr ""
msgid "PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab."
msgstr ""
......
......@@ -34,6 +34,8 @@ exports[`packages_list_row renders 1`] = `
</gl-link-stub>
<!---->
<!---->
</div>
<!---->
......
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
import PackagePath from '~/packages/shared/components/package_path.vue';
import PackageTags from '~/packages/shared/components/package_tags.vue';
import { PACKAGE_ERROR_STATUS } from '~/packages/shared/constants';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import { packageList } from '../../mock_data';
......@@ -20,7 +23,10 @@ describe('packages_list_row', () => {
const findPackagePath = () => wrapper.find(PackagePath);
const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]');
const findPackageIconAndName = () => wrapper.find(PackageIconAndName);
const findInfrastructureIconAndName = () => wrapper.find(InfrastructureIconAndName);
const findInfrastructureIconAndName = () => wrapper.findComponent(InfrastructureIconAndName);
const findListItem = () => wrapper.findComponent(ListItem);
const findPackageLink = () => wrapper.findComponent(GlLink);
const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]');
const mountComponent = ({
isGroup = false,
......@@ -44,6 +50,9 @@ describe('packages_list_row', () => {
showPackageType,
disableDelete,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
......@@ -146,4 +155,31 @@ describe('packages_list_row', () => {
expect(findInfrastructureIconAndName().exists()).toBe(true);
});
});
describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => {
beforeEach(() => {
mountComponent({ packageEntity: { ...packageWithoutTags, status: PACKAGE_ERROR_STATUS } });
});
it('list item has a disabled prop', () => {
expect(findListItem().props('disabled')).toBe(true);
});
it('details link is disabled', () => {
expect(findPackageLink().attributes('disabled')).toBe('true');
});
it('has a warning icon', () => {
const icon = findWarningIcon();
const tooltip = getBinding(icon.element, 'gl-tooltip');
expect(icon.props('icon')).toBe('warning');
expect(tooltip.value).toMatchObject({
title: 'Invalid Package: failed metadata extraction',
});
});
it('delete button is disabled', () => {
expect(findDeleteButton().props('disabled')).toBe(true);
});
});
});
......@@ -39,48 +39,66 @@ describe('PackagePath', () => {
const pathPieces = path.split('/').slice(1);
const hasTooltip = shouldExist.includes(ELLIPSIS_ICON);
beforeEach(() => {
mountComponent({ path });
});
describe('not disabled component', () => {
beforeEach(() => {
mountComponent({ path });
});
it('should have a base icon', () => {
expect(findItem(BASE_ICON).exists()).toBe(true);
});
it('should have a base icon', () => {
expect(findItem(BASE_ICON).exists()).toBe(true);
});
it('should have a root link', () => {
const root = findItem(ROOT_LINK);
expect(root.exists()).toBe(true);
expect(root.attributes('href')).toBe(rootUrl);
});
it('should have a root link', () => {
const root = findItem(ROOT_LINK);
expect(root.exists()).toBe(true);
expect(root.attributes('href')).toBe(rootUrl);
});
if (hasTooltip) {
it('should have a tooltip', () => {
const tooltip = findTooltip(findItem(ELLIPSIS_ICON));
expect(tooltip).toBeDefined();
expect(tooltip.value).toMatchObject({
title: path,
if (hasTooltip) {
it('should have a tooltip', () => {
const tooltip = findTooltip(findItem(ELLIPSIS_ICON));
expect(tooltip).toBeDefined();
expect(tooltip.value).toMatchObject({
title: path,
});
});
});
}
}
if (shouldExist.length) {
it.each(shouldExist)(`should have %s`, (element) => {
expect(findItem(element).exists()).toBe(true);
});
}
if (shouldExist.length) {
it.each(shouldExist)(`should have %s`, (element) => {
expect(findItem(element).exists()).toBe(true);
});
}
if (shouldNotExist.length) {
it.each(shouldNotExist)(`should not have %s`, (element) => {
expect(findItem(element).exists()).toBe(false);
if (shouldNotExist.length) {
it.each(shouldNotExist)(`should not have %s`, (element) => {
expect(findItem(element).exists()).toBe(false);
});
}
if (shouldExist.includes(LEAF_LINK)) {
it('the last link should be the last piece of the path', () => {
const leaf = findItem(LEAF_LINK);
expect(leaf.attributes('href')).toBe(`/${path}`);
expect(leaf.text()).toBe(pathPieces[pathPieces.length - 1]);
});
}
});
describe('disabled component', () => {
beforeEach(() => {
mountComponent({ path, disabled: true });
});
}
if (shouldExist.includes(LEAF_LINK)) {
it('the last link should be the last piece of the path', () => {
const leaf = findItem(LEAF_LINK);
expect(leaf.attributes('href')).toBe(`/${path}`);
expect(leaf.text()).toBe(pathPieces[pathPieces.length - 1]);
it('root link is disabled', () => {
expect(findItem(ROOT_LINK).attributes('disabled')).toBe('true');
});
}
if (shouldExist.includes(LEAF_LINK)) {
it('the last link is disabled', () => {
expect(findItem(LEAF_LINK).attributes('disabled')).toBe('true');
});
}
});
});
});
import { GlFormCheckbox, GlSprintf, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import DeleteButton from '~/registry/explorer/components/delete_button.vue';
......@@ -72,8 +73,15 @@ describe('tags list row', () => {
expect(findCheckbox().exists()).toBe(false);
});
it('is disabled when the digest is missing', () => {
mountComponent({ tag: { ...tag, digest: null } });
it.each`
digest | disabled
${'foo'} | ${true}
${null} | ${false}
${null} | ${true}
${'foo'} | ${true}
`('is disabled when the digest $digest and disabled is $disabled', ({ digest, disabled }) => {
mountComponent({ tag: { ...tag, digest }, disabled });
expect(findCheckbox().attributes('disabled')).toBe('true');
});
......@@ -141,6 +149,12 @@ describe('tags list row', () => {
title: tag.location,
});
});
it('is disabled when the component is disabled', () => {
mountComponent({ ...defaultProps, disabled: true });
expect(findClipboardButton().attributes('disabled')).toBe('true');
});
});
describe('warning icon', () => {
......@@ -266,15 +280,19 @@ describe('tags list row', () => {
});
it.each`
canDelete | digest
${true} | ${null}
${false} | ${'foo'}
${false} | ${null}
`('is disabled when canDelete is $canDelete and digest is $digest', ({ canDelete, digest }) => {
mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest } });
expect(findDeleteButton().attributes('disabled')).toBe('true');
});
canDelete | digest | disabled
${true} | ${null} | ${true}
${false} | ${'foo'} | ${true}
${false} | ${null} | ${true}
${true} | ${'foo'} | ${true}
`(
'is disabled when canDelete is $canDelete and digest is $digest and disabled is $disabled',
({ canDelete, digest, disabled }) => {
mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled });
expect(findDeleteButton().attributes('disabled')).toBe('true');
},
);
it('delete event emits delete', () => {
mountComponent();
......@@ -287,13 +305,10 @@ describe('tags list row', () => {
describe('details rows', () => {
describe('when the tag has a digest', () => {
beforeEach(() => {
it('has 3 details rows', async () => {
mountComponent();
await nextTick();
return wrapper.vm.$nextTick();
});
it('has 3 details rows', () => {
expect(findDetailsRows().length).toBe(3);
});
......@@ -303,17 +318,37 @@ describe('tags list row', () => {
${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true}
${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true}
`('$name details row', ({ finderFunction, text, icon, clipboard }) => {
it(`has ${text} as text`, () => {
it(`has ${text} as text`, async () => {
mountComponent();
await nextTick();
expect(finderFunction().text()).toMatchInterpolatedText(text);
});
it(`has the ${icon} icon`, () => {
it(`has the ${icon} icon`, async () => {
mountComponent();
await nextTick();
expect(finderFunction().props('icon')).toBe(icon);
});
it(`is ${clipboard} that clipboard button exist`, () => {
expect(finderFunction().find(ClipboardButton).exists()).toBe(clipboard);
});
if (clipboard) {
it(`clipboard button exist`, async () => {
mountComponent();
await nextTick();
expect(finderFunction().find(ClipboardButton).exists()).toBe(clipboard);
});
it('is disabled when the component is disabled', async () => {
mountComponent({ ...defaultProps, disabled: true });
await nextTick();
expect(finderFunction().findComponent(ClipboardButton).attributes('disabled')).toBe(
'true',
);
});
}
});
});
......@@ -321,7 +356,7 @@ describe('tags list row', () => {
it('hides the details rows', async () => {
mountComponent({ tag: { ...tag, digest: null } });
await wrapper.vm.$nextTick();
await nextTick();
expect(findDetailsRows().length).toBe(0);
});
});
......
......@@ -25,10 +25,11 @@ describe('Image List Row', () => {
const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
const findTagsCount = () => wrapper.find('[data-testid="tags-count"]');
const findDeleteBtn = () => wrapper.find(DeleteButton);
const findClipboardButton = () => wrapper.find(ClipboardButton);
const findDeleteBtn = () => wrapper.findComponent(DeleteButton);
const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]');
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findListItemComponent = () => wrapper.findComponent(ListItem);
const mountComponent = (props) => {
wrapper = shallowMount(Component, {
......@@ -52,20 +53,28 @@ describe('Image List Row', () => {
wrapper = null;
});
describe('main tooltip', () => {
it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
mountComponent();
describe('list item component', () => {
describe('tooltip', () => {
it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
mountComponent();
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
});
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
it('is disabled when item is being deleted', () => {
mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(false);
});
});
it('is disabled when item is being deleted', () => {
it('is disabled when the item is in deleting status', () => {
mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(false);
expect(findListItemComponent().props('disabled')).toBe(true);
});
});
......@@ -118,6 +127,20 @@ describe('Image List Row', () => {
},
);
});
describe('when the item is deleting', () => {
beforeEach(() => {
mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
});
it('the router link is disabled', () => {
// we check the event prop as is the only workaround to disable a router link
expect(findDetailsLink().props('event')).toBe('');
});
it('the clipboard button is disabled', () => {
expect(findClipboardButton().attributes('disabled')).toBe('true');
});
});
});
describe('delete button', () => {
......
......@@ -101,16 +101,16 @@ describe('list item', () => {
});
describe('disabled prop', () => {
it('when true applies disabled-content class', () => {
it('when true applies gl-opacity-5 class', () => {
mountComponent({ disabled: true });
expect(wrapper.classes('disabled-content')).toBe(true);
expect(wrapper.classes('gl-opacity-5')).toBe(true);
});
it('when false does not apply disabled-content class', () => {
it('when false does not apply gl-opacity-5 class', () => {
mountComponent({ disabled: false });
expect(wrapper.classes('disabled-content')).toBe(false);
expect(wrapper.classes('gl-opacity-5')).toBe(false);
});
});
......
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