Commit ec98ba6c authored by Nick Kipling's avatar Nick Kipling

Updates the package registry list design

Created new package_list_row component
Added pipeline information into each row
Added mobile styles
Removed old table components
Updated tests
Updated pot file
parent 2dd2a07f
import { s__ } from '~/locale';
import { generateConanRecipe } from '../utils';
import { getPackageType } from '../../shared/utils';
import { NpmManager } from '../constants';
export const packagePipeline = ({ packageEntity }) => {
......@@ -7,19 +7,7 @@ export const packagePipeline = ({ packageEntity }) => {
};
export const packageTypeDisplay = ({ packageEntity }) => {
switch (packageEntity.package_type) {
case 'conan':
return s__('PackageType|Conan');
case 'maven':
return s__('PackageType|Maven');
case 'npm':
return s__('PackageType|NPM');
case 'nuget':
return s__('PackageType|NuGet');
default:
return null;
}
return getPackageType(packageEntity.package_type);
};
export const conanInstallationCommand = ({ packageEntity }) => {
......
<script>
import { mapState, mapGetters } from 'vuex';
import {
GlTable,
GlPagination,
GlDeprecatedButton,
GlModal,
GlLink,
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import { GlPagination, GlModal, GlTooltipDirective } from '@gitlab/ui';
import Tracking from '~/tracking';
import { s__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { LIST_KEY_ACTIONS, LIST_LABEL_ACTIONS } from '../constants';
import getTableHeaders from '../utils';
import { TrackingActions } from '../../shared/constants';
import { packageTypeToTrackCategory } from '../../shared/utils';
import PackageTags from '../../shared/components/package_tags.vue';
import PackagesListLoader from './packages_list_loader.vue';
import PackagesListRow from './packages_list_row.vue';
export default {
components: {
GlTable,
GlPagination,
GlDeprecatedButton,
GlLink,
TimeAgoTooltip,
GlModal,
GlIcon,
PackageTags,
PackagesListLoader,
PackagesListRow,
},
directives: { GlTooltip: GlTooltipDirective },
mixins: [Tracking.mixin()],
......@@ -58,22 +42,6 @@ export default {
isListEmpty() {
return !this.list || this.list.length === 0;
},
showActions() {
return !this.isGroupPage;
},
headerFields() {
const fields = getTableHeaders(this.isGroupPage);
if (this.showActions) {
fields.push({
key: LIST_KEY_ACTIONS,
label: LIST_LABEL_ACTIONS,
});
}
fields[fields.length - 1].class = ['text-right'];
return fields;
},
modalAction() {
return s__('PackageRegistry|Delete package');
},
......@@ -121,84 +89,26 @@ export default {
<div class="d-flex flex-column">
<slot v-if="isListEmpty && !isLoading" name="empty-state"></slot>
<template v-else>
<gl-table
:items="list"
:fields="headerFields"
:no-local-sorting="true"
:busy="isLoading"
stacked="md"
class="package-list-table"
data-qa-selector="packages-table"
>
<template #table-busy>
<packages-list-loader :is-group="isGroupPage" />
</template>
<div v-else-if="isLoading">
<packages-list-loader :is-group="isGroupPage" />
</div>
<template #cell(name)="{value, item}">
<div
class="flex-truncate-parent d-flex align-items-center justify-content-end justify-content-md-start"
>
<gl-link
v-gl-tooltip.hover
:title="value"
:href="item._links.web_path"
data-qa-selector="package_link"
>
{{ value }}
</gl-link>
<package-tags
v-if="item.tags && item.tags.length"
class="prepend-left-8"
:tags="item.tags"
hide-label
:tag-display-limit="1"
/>
</div>
</template>
<template v-else>
<div data-qa-selector="packages-table">
<packages-list-row
v-for="pk in list"
:key="pk.id"
:package-entity="pk"
@packageToDelete="setItemToBeDeleted"
/>
</div>
<template #cell(project_path)="{item}">
<div ref="col-project" class="flex-truncate-parent">
<gl-link
v-gl-tooltip.hover
:title="item.projectPathName"
:href="`/${item.project_path}`"
class="flex-truncate-child"
>
{{ item.projectPathName }}
</gl-link>
</div>
</template>
<template #cell(version)="{value}">
{{ value }}
</template>
<template #cell(package_type)="{value}">
{{ value }}
</template>
<template #cell(created_at)="{value}">
<time-ago-tooltip :time="value" />
</template>
<template #cell(actions)="{item}">
<!-- _links contains the urls needed to navigate to the page details and to perform a package deletion and it comes straight from the API -->
<gl-deprecated-button
ref="action-delete"
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"
:disabled="!item._links.delete_api_path"
@click="setItemToBeDeleted(item)"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</template>
</gl-table>
<gl-pagination
v-model="currentPage"
:per-page="perPage"
:total-items="totalItems"
align="center"
class="w-100"
class="w-100 mt-2"
/>
<gl-modal
......
<script>
import PackageTags from '../../shared/components/package_tags.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import { getPackageType } from '../../shared/utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { mapGetters, mapState } from 'vuex';
export default {
name: 'PackagesListRow',
components: {
ClipboardButton,
GlButton,
GlIcon,
GlLink,
GlSprintf,
PackageTags,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
packageEntity: {
type: Object,
required: true,
},
},
computed: {
...mapGetters(['getCommitLink']),
...mapState({
isGroupPage: state => state.config.isGroupPage,
}),
author() {
return this.packageEntity.pipeline?.user.name;
},
hasPipeline() {
return Boolean(this.packageEntity.pipeline);
},
createdBy() {
if (this.hasPipeline) {
return s__('PackageRegistry|%{version} published by %{author}');
}
return '%{version}';
},
packageType() {
return getPackageType(this.packageEntity.package_type);
},
packageShaShort() {
return this.packageEntity.pipeline.sha.substring(0, 8);
},
linkToCommit() {
return this.getCommitLink(this.packageEntity);
},
hasProjectLink() {
return Boolean(this.packageEntity.project_path);
},
deleteAvailable() {
return !this.isGroupPage;
},
},
};
</script>
<template>
<div class="gl-responsive-table-row" data-qa-selector="packages-row">
<div class="table-section section-50 d-flex flex-md-column justify-content-between flex-wrap">
<div class="d-flex align-items-center mr-2">
<gl-link :href="packageEntity._links.web_path" class="text-dark font-weight-bold mb-md-1">{{
packageEntity.name
}}</gl-link>
<package-tags
v-if="packageEntity.tags && packageEntity.tags.length"
class="prepend-left-8"
:tags="packageEntity.tags"
hide-label
:tag-display-limit="1"
/>
</div>
<div class="d-flex text-secondary text-truncate">
<gl-sprintf :message="createdBy">
<template #version>
{{ packageEntity.version }}
</template>
<template #author>{{ packageEntity.pipeline.user.name }}</template>
</gl-sprintf>
<gl-link
v-if="hasProjectLink"
ref="packages-row-project"
:href="`/${packageEntity.project_path}`"
class="text-secondary ml-2"
>{{ packageEntity.projectPathName }}</gl-link
>
<div class="d-flex align-items-center">
<gl-icon name="package" class="text-secondary ml-2 mr-1" />
<span ref="package-type">{{ packageType }}</span>
</div>
</div>
</div>
<div
class="table-section section-40 d-flex flex-md-column justify-content-between align-items-md-end"
:class="{ 'section-50': isGroupPage }"
>
<div
v-if="hasPipeline"
class="d-flex align-items-center text-secondary order-1 order-md-0 mb-md-1"
>
<gl-icon name="git-merge" class="mr-1" />
<strong ref="pipeline-ref" class="mr-1 text-dark">{{ packageEntity.pipeline.ref }}</strong>
<gl-icon name="commit" class="mr-1" />
<gl-link ref="pipeline-sha" :href="linkToCommit" class="mr-1">{{
packageShaShort
}}</gl-link>
<clipboard-button
:text="packageEntity.pipeline.sha"
:title="__('Copy commit SHA')"
css-class="border-0 text-secondary py-0 px-1"
/>
</div>
<div class="text-secondary order-0 order-md-1">
<gl-sprintf :message="__('Created %{timestamp}')">
<template #timestamp>
<span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)">
{{ timeFormatted(packageEntity.created_at) }}
</span>
</template>
</gl-sprintf>
</div>
</div>
<div v-if="deleteAvailable" class="table-section section-10 d-flex justify-content-end">
<gl-button
ref="action-delete"
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"
:disabled="!packageEntity._links.delete_api_path"
@click="$emit('packageToDelete', packageEntity)"
>
<gl-icon name="remove" />
</gl-button>
</div>
</div>
</template>
import { LIST_KEY_PROJECT } from '../constants';
import { beautifyPath } from '../../shared/utils';
// eslint-disable-next-line import/prefer-default-export
export const getList = state =>
state.packages.map(p => ({ ...p, projectPathName: beautifyPath(p[LIST_KEY_PROJECT]) }));
export const getCommitLink = ({ config }) => ({ project_path: projectPath, pipeline = {} }) => {
if (config.isGroupPage) {
return `/${projectPath}/commit/${pipeline.sha}`;
}
return `../commit/${pipeline.sha}`;
};
import { s__ } from '~/locale';
import { TrackingCategories } from './constants';
export const packageTypeToTrackCategory = type =>
......@@ -5,3 +6,19 @@ export const packageTypeToTrackCategory = type =>
`UI::${TrackingCategories[type]}`;
export const beautifyPath = path => (path ? path.split('/').join(' / ') : '');
export const getPackageType = packageType => {
switch (packageType) {
case 'conan':
return s__('PackageType|Conan');
case 'maven':
return s__('PackageType|Maven');
case 'npm':
return s__('PackageType|NPM');
case 'nuget':
return s__('PackageType|NuGet');
default:
return null;
}
};
---
title: Updates the package registry list UI which also includes adding pipeline information
merge_request: 28426
author:
type: added
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_list_row renders 1`] = `
<div
class="gl-responsive-table-row"
data-qa-selector="packages-row"
>
<div
class="table-section section-50 d-flex flex-md-column justify-content-between flex-wrap"
>
<div
class="d-flex align-items-center mr-2"
>
<gl-link-stub
class="text-dark font-weight-bold mb-md-1"
href="foo"
>
Test package
</gl-link-stub>
<!---->
</div>
<div
class="d-flex text-secondary text-truncate"
>
<gl-sprintf-stub
message="%{version}"
/>
<gl-link-stub
class="text-secondary ml-2"
href="/foo/bar/baz"
>
</gl-link-stub>
<div
class="d-flex align-items-center"
>
<gl-icon-stub
class="text-secondary ml-2 mr-1"
name="package"
size="16"
/>
<span>
Maven
</span>
</div>
</div>
</div>
<div
class="table-section section-40 d-flex flex-md-column justify-content-between align-items-md-end"
>
<!---->
<div
class="text-secondary order-0 order-md-1"
>
<gl-sprintf-stub
message="Created %{timestamp}"
/>
</div>
</div>
<div
class="table-section section-10 d-flex justify-content-end"
>
<gl-button-stub
aria-label="Remove package"
size="md"
title="Remove package"
variant="danger"
>
<gl-icon-stub
name="remove"
size="16"
/>
</gl-button-stub>
</div>
</div>
`;
import Vuex from 'vuex';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import PackagesListRow from 'ee/packages/list/components/packages_list_row.vue';
import PackageTags from 'ee/packages/shared/components/package_tags.vue';
import { packageList } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('packages_list_row', () => {
let wrapper;
let store;
const [packageWithoutTagsOrPipeline, packageWithTabsAndPipeline] = packageList;
const findPackageTags = () => wrapper.find(PackageTags);
const findProjectLink = () => wrapper.find({ ref: 'packages-row-project' });
const findDeleteButton = () => wrapper.find({ ref: 'action-delete' });
const findPipelineRef = () => wrapper.find({ ref: 'pipeline-ref' });
const findPipelineSha = () => wrapper.find({ ref: 'pipeline-sha' });
const mountComponent = (
isGroupPage = false,
packageEntity = packageWithoutTagsOrPipeline,
shallow = true,
) => {
const mountFunc = shallow ? shallowMount : mount;
const state = {
config: {
isGroupPage,
},
};
store = new Vuex.Store({
state,
getters: {
getCommitLink: () => () => {
return 'commit-link';
},
},
});
wrapper = mountFunc(PackagesListRow, {
localVue,
store,
propsData: {
packageEntity,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
});
describe('tags', () => {
it('renders package tags when a package has tags', () => {
mountComponent(false, packageWithTabsAndPipeline);
expect(findPackageTags().exists()).toBe(true);
});
it('does not render when there are no tags', () => {
mountComponent();
expect(findPackageTags().exists()).toBe(false);
});
});
describe('when is isGroupPage', () => {
beforeEach(() => {
mountComponent(true);
});
it('has project field', () => {
expect(findProjectLink().exists()).toBe(true);
});
it('does not show the delete button', () => {
expect(findDeleteButton().exists()).toBe(false);
});
});
describe('pipeline information', () => {
it('displays branch and commit when pipeline info exists', () => {
mountComponent(false, packageWithTabsAndPipeline);
expect(findPipelineRef().exists()).toBe(true);
expect(findPipelineSha().exists()).toBe(true);
});
it('does not show any pipeline details when no information exists', () => {
mountComponent(false, packageWithoutTagsOrPipeline);
expect(findPipelineRef().exists()).toBe(false);
expect(findPipelineSha().exists()).toBe(false);
});
});
describe('delete event', () => {
beforeEach(() => mountComponent(false, packageWithoutTagsOrPipeline, false));
it('emits the packageToDelete event when the delete button is clicked', () => {
findDeleteButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('packageToDelete')).toBeTruthy();
expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTagsOrPipeline]);
});
});
});
});
......@@ -4,7 +4,6 @@ import { GlTable, GlPagination, GlModal } from '@gitlab/ui';
import Tracking from '~/tracking';
import { mount, createLocalVue } from '@vue/test-utils';
import PackagesList from 'ee/packages/list/components/packages_list.vue';
import PackageTags from 'ee/packages/shared/components/package_tags.vue';
import PackagesListLoader from 'ee/packages/list/components/packages_list_loader.vue';
import * as SharedUtils from 'ee/packages/shared/utils';
import { TrackingActions } from 'ee/packages/shared/constants';
......@@ -22,12 +21,8 @@ describe('packages_list', () => {
const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' };
const findPackagesListLoader = () => wrapper.find(PackagesListLoader);
const findFirstActionColumn = () => wrapper.find({ ref: 'action-delete' });
const findPackageListTable = () => wrapper.find(GlTable);
const findPackageListPagination = () => wrapper.find(GlPagination);
const findPackageListDeleteModal = () => wrapper.find(GlModal);
const findFirstProjectColumn = () => wrapper.find({ ref: 'col-project' });
const findPackageTags = () => wrapper.findAll(PackageTags);
const findEmptySlot = () => wrapper.find({ name: 'empty-slot-stub' });
const createStore = (isGroupPage, packages, isLoading) => {
......@@ -104,32 +99,11 @@ describe('packages_list', () => {
});
});
describe('when is isGroupPage', () => {
beforeEach(() => {
mountComponent({ isGroupPage: true });
});
it('has project field', () => {
const projectColumn = findFirstProjectColumn();
expect(projectColumn.exists()).toBe(true);
});
it('does not show the action column', () => {
const action = findFirstActionColumn();
expect(action.exists()).toBe(false);
});
});
describe('layout', () => {
beforeEach(() => {
mountComponent();
});
it('contains a table component', () => {
const sorting = findPackageListTable();
expect(sorting.exists()).toBe(true);
});
it('contains a pagination component', () => {
const sorting = findPackageListPagination();
expect(sorting.exists()).toBe(true);
......@@ -139,10 +113,6 @@ describe('packages_list', () => {
const sorting = findPackageListDeleteModal();
expect(sorting.exists()).toBe(true);
});
it('renders package tags when a package has tags', () => {
expect(findPackageTags()).toHaveLength(1);
});
});
describe('when the user can destroy the package', () => {
......@@ -150,11 +120,6 @@ describe('packages_list', () => {
mountComponent();
});
it('show the action column', () => {
const action = findFirstActionColumn();
expect(action.exists()).toBe(true);
});
it('shows the correct deletePackageDescription', () => {
expect(wrapper.vm.deletePackageDescription).toEqual('');
......@@ -164,11 +129,12 @@ describe('packages_list', () => {
);
});
it('delete button set itemToBeDeleted and open the modal', () => {
it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => {
wrapper.vm.$refs.packageListDeleteModal.show = jest.fn();
const item = last(wrapper.vm.list);
const action = findFirstActionColumn();
action.vm.$emit('click');
wrapper.vm.setItemToBeDeleted(item);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.itemToBeDeleted).toEqual(item);
expect(wrapper.vm.$refs.packageListDeleteModal.show).toHaveBeenCalled();
......@@ -208,9 +174,7 @@ describe('packages_list', () => {
});
it('show the empty slot', () => {
const table = findPackageListTable();
const emptySlot = findEmptySlot();
expect(table.exists()).toBe(false);
expect(emptySlot.exists()).toBe(true);
});
});
......@@ -232,17 +196,6 @@ describe('packages_list', () => {
});
});
describe('table component', () => {
beforeEach(() => {
mountComponent();
});
it('has stacked-md class', () => {
const table = findPackageListTable();
expect(table.classes()).toContain('b-table-stacked-md');
});
});
describe('tracking', () => {
let eventSpy;
let utilSpy;
......
......@@ -2,17 +2,54 @@ import * as getters from 'ee/packages/list/stores/getters';
import { packageList } from '../../mock_data';
describe('Getters registry list store', () => {
const state = {
packages: packageList,
let state;
const setState = (isGroupPage = false) => {
state = {
packages: packageList,
config: {
isGroupPage,
},
};
};
beforeEach(() => setState());
afterEach(() => {
state = null;
});
describe('getList', () => {
const result = getters.getList(state);
it('returns a list of packages', () => {
const result = getters.getList(state);
expect(result).toHaveLength(packageList.length);
expect(result[0].name).toBe('Test package');
});
it('adds projectPathName', () => {
const result = getters.getList(state);
expect(result[0].projectPathName).toMatchInlineSnapshot(`"foo / bar / baz"`);
});
});
describe('getCommitLink', () => {
it('returns a relative link when isGroupPage is false', () => {
const link = getters.getCommitLink(state)(packageList[0]);
expect(link).toContain('../commit');
});
describe('when isGroupPage is true', () => {
beforeEach(() => setState(true));
it('returns an absolute link matching project path', () => {
const mavenPackage = packageList[0];
const link = getters.getCommitLink(state)(mavenPackage);
expect(link).toContain(`/${mavenPackage.project_path}/commit`);
});
});
});
});
......@@ -7,6 +7,9 @@ export const mockPipelineInfo = {
id: 1,
ref: 'branch-name',
sha: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
user: {
name: 'foo',
},
};
export const mavenPackage = {
......
import { packageTypeToTrackCategory, beautifyPath } from 'ee/packages/shared/utils';
import { packageTypeToTrackCategory, beautifyPath, getPackageType } from 'ee/packages/shared/utils';
import { PackageType, TrackingCategories } from 'ee/packages/shared/constants';
describe('Packages shared utils', () => {
......@@ -14,6 +14,7 @@ describe('Packages shared utils', () => {
);
});
});
describe('beautifyPath', () => {
it('returns a string with spaces around /', () => {
expect(beautifyPath('foo/bar')).toBe('foo / bar');
......@@ -22,4 +23,18 @@ describe('Packages shared utils', () => {
expect(beautifyPath()).toBe('');
});
});
describe('getPackageType', () => {
describe.each`
packageType | expectedResult
${'conan'} | ${'Conan'}
${'maven'} | ${'Maven'}
${'npm'} | ${'NPM'}
${'nuget'} | ${'NuGet'}
`(`package type`, ({ packageType, expectedResult }) => {
it(`${packageType} should show as ${expectedResult}`, () => {
expect(getPackageType(packageType)).toBe(expectedResult);
});
});
});
});
......@@ -14,7 +14,7 @@ RSpec.shared_examples 'packages list' do |check_project_name: false|
end
def package_table_row(index)
page.all("#{packages_table_selector} tbody tr")[index].text
page.all("#{packages_table_selector} > [data-qa-selector=\"packages-row\"]")[index].text
end
end
......
......@@ -6067,6 +6067,9 @@ msgstr ""
msgid "Created"
msgstr ""
msgid "Created %{timestamp}"
msgstr ""
msgid "Created At"
msgstr ""
......@@ -14260,6 +14263,9 @@ msgstr ""
msgid "Package was removed"
msgstr ""
msgid "PackageRegistry|%{version} published by %{author}"
msgstr ""
msgid "PackageRegistry|Add Conan Remote"
msgstr ""
......
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