Commit 4aaaa813 authored by Nathan Friend's avatar Nathan Friend Committed by Nicolò Maria Mezzopera

Show Release assets with type in accordion

This commit updates the "Assets" section on Release blocks to be
collapsible (collapsed by default) and to be grouped by asset type.
parent 00477e9d
<script> <script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ASSET_LINK_TYPE } from '../constants';
import { __, s__, sprintf } from '~/locale';
import { difference } from 'lodash';
export default { export default {
name: 'ReleaseBlockAssets', name: 'ReleaseBlockAssets',
components: { components: {
GlLink, GlLink,
GlButton,
GlCollapse,
GlIcon,
Icon, Icon,
GlBadge,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
assets: { assets: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
data() {
return {
isAssetsExpanded: true,
};
},
computed: { computed: {
hasAssets() { hasAssets() {
return Boolean(this.assets.count); return Boolean(this.assets.count);
}, },
imageLinks() {
return this.linksForType(ASSET_LINK_TYPE.IMAGE);
},
packageLinks() {
return this.linksForType(ASSET_LINK_TYPE.PACKAGE);
},
runbookLinks() {
return this.linksForType(ASSET_LINK_TYPE.RUNBOOK);
},
otherLinks() {
return difference(this.assets.links, [
...this.imageLinks,
...this.packageLinks,
...this.runbookLinks,
]);
},
sections() {
return [
{
links: this.assets.sources.map(s => ({
url: s.url,
name: sprintf(__('Source code (%{fileExtension})'), { fileExtension: s.format }),
})),
iconName: 'doc-code',
},
{
title: s__('ReleaseAssetLinkType|Images'),
links: this.imageLinks,
iconName: 'container-image',
},
{
title: s__('ReleaseAssetLinkType|Packages'),
links: this.packageLinks,
iconName: 'package',
},
{
title: s__('ReleaseAssetLinkType|Runbooks'),
links: this.runbookLinks,
iconName: 'book',
},
{
title: s__('ReleaseAssetLinkType|Other'),
links: this.otherLinks,
iconName: 'link',
},
].filter(section => section.links.length > 0);
},
},
methods: {
toggleAssetsExpansion() {
this.isAssetsExpanded = !this.isAssetsExpanded;
},
linksForType(type) {
return this.assets.links.filter(l => l.linkType === type);
},
}, },
externalLinkTooltipText: __('This link points to external content'),
}; };
</script> </script>
<template> <template>
<div class="card-text prepend-top-default"> <div class="card-text prepend-top-default">
<b> <template v-if="glFeatures.releaseAssetLinkType">
{{ __('Assets') }} <gl-button
<span class="js-assets-count badge badge-pill">{{ assets.count }}</span> data-testid="accordion-button"
</b> variant="link"
class="gl-font-weight-bold"
<ul v-if="assets.links.length" class="pl-0 mb-0 gl-mt-3 list-unstyled js-assets-list"> @click="toggleAssetsExpansion"
<li v-for="link in assets.links" :key="link.name" class="append-bottom-8">
<gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl">
<icon name="package" class="align-middle append-right-4 align-text-bottom" />
{{ link.name }}
<span v-if="link.external">{{ __('(external source)') }}</span>
</gl-link>
</li>
</ul>
<div v-if="hasAssets" class="dropdown">
<button
type="button"
class="btn btn-link"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
> >
<icon name="doc-code" class="align-top append-right-4" /> <gl-icon
{{ __('Source code') }} name="chevron-right"
<icon name="chevron-down" /> class="gl-transition-medium"
</button> :class="{ 'gl-rotate-90': isAssetsExpanded }"
/>
{{ __('Assets') }}
<gl-badge size="sm" variant="neutral" class="gl-display-inline-block">{{
assets.count
}}</gl-badge>
</gl-button>
<gl-collapse v-model="isAssetsExpanded">
<div class="gl-pl-6 gl-pt-3 js-assets-list">
<template v-for="(section, index) in sections">
<h5 v-if="section.title" :key="`section-header-${index}`" class="gl-mb-2">
{{ section.title }}
</h5>
<ul :key="`section-body-${index}`" class="list-unstyled gl-m-0">
<li v-for="link in section.links" :key="link.url">
<gl-link
:href="link.directAssetUrl || link.url"
class="gl-display-flex gl-align-items-center gl-line-height-24"
>
<gl-icon
:name="section.iconName"
class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0"
/>
{{ link.name }}
<gl-icon
v-if="link.external"
v-gl-tooltip
name="external-link"
:aria-label="$options.externalLinkTooltipText"
:title="$options.externalLinkTooltipText"
data-testid="external-link-indicator"
class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-600"
/>
</gl-link>
</li>
</ul>
</template>
</div>
</gl-collapse>
</template>
<div class="js-sources-dropdown dropdown-menu"> <template v-else>
<li v-for="asset in assets.sources" :key="asset.url"> <b>
<gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> {{ __('Assets') }}
<span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
</b>
<ul v-if="assets.links.length" class="pl-0 mb-0 gl-mt-3 list-unstyled js-assets-list">
<li v-for="link in assets.links" :key="link.name" class="append-bottom-8">
<gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl">
<icon name="package" class="align-middle append-right-4 align-text-bottom" />
{{ link.name }}
<span v-if="link.external" data-testid="external-link-indicator">{{
__('(external source)')
}}</span>
</gl-link>
</li> </li>
</ul>
<div v-if="hasAssets" class="dropdown">
<button
type="button"
class="btn btn-link"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
<icon name="doc-code" class="align-top append-right-4" />
{{ __('Source code') }}
<icon name="chevron-down" />
</button>
<div class="js-sources-dropdown dropdown-menu">
<li v-for="asset in assets.sources" :key="asset.url">
<gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
</li>
</div>
</div> </div>
</div> </template>
</div> </div>
</template> </template>
...@@ -18341,15 +18341,24 @@ msgstr "" ...@@ -18341,15 +18341,24 @@ msgstr ""
msgid "ReleaseAssetLinkType|Image" msgid "ReleaseAssetLinkType|Image"
msgstr "" msgstr ""
msgid "ReleaseAssetLinkType|Images"
msgstr ""
msgid "ReleaseAssetLinkType|Other" msgid "ReleaseAssetLinkType|Other"
msgstr "" msgstr ""
msgid "ReleaseAssetLinkType|Package" msgid "ReleaseAssetLinkType|Package"
msgstr "" msgstr ""
msgid "ReleaseAssetLinkType|Packages"
msgstr ""
msgid "ReleaseAssetLinkType|Runbook" msgid "ReleaseAssetLinkType|Runbook"
msgstr "" msgstr ""
msgid "ReleaseAssetLinkType|Runbooks"
msgstr ""
msgid "Releases" msgid "Releases"
msgstr "" msgstr ""
...@@ -20992,6 +21001,9 @@ msgstr "" ...@@ -20992,6 +21001,9 @@ msgstr ""
msgid "Source code" msgid "Source code"
msgstr "" msgstr ""
msgid "Source code (%{fileExtension})"
msgstr ""
msgid "Source is not available" msgid "Source is not available"
msgstr "" msgstr ""
...@@ -22894,6 +22906,9 @@ msgstr "" ...@@ -22894,6 +22906,9 @@ msgstr ""
msgid "This license has already expired." msgid "This license has already expired."
msgstr "" msgstr ""
msgid "This link points to external content"
msgstr ""
msgid "This may expose confidential information as the selected fork is in another namespace that can have other members." msgid "This may expose confidential information as the selected fork is in another namespace that can have other members."
msgstr "" msgstr ""
......
...@@ -26,47 +26,65 @@ RSpec.describe 'User views releases', :js do ...@@ -26,47 +26,65 @@ RSpec.describe 'User views releases', :js do
expect(page).not_to have_content('Upcoming Release') expect(page).not_to have_content('Upcoming Release')
end end
context 'when there is a link as an asset' do shared_examples 'asset link tests' do
let!(:release_link) { create(:release_link, release: release, url: url ) } context 'when there is a link as an asset' do
let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" } let!(:release_link) { create(:release_link, release: release, url: url ) }
let(:direct_asset_link) { Gitlab::Routing.url_helpers.project_release_url(project, release) << release_link.filepath }
it 'sees the link' do
visit project_releases_path(project)
page.within('.js-assets-list') do
expect(page).to have_link release_link.name, href: direct_asset_link
expect(page).not_to have_content('(external source)')
end
end
context 'when there is a link redirect' do
let!(:release_link) { create(:release_link, release: release, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: url) }
let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" } let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
let(:direct_asset_link) { Gitlab::Routing.url_helpers.project_release_url(project, release) << release_link.filepath }
it 'sees the link' do it 'sees the link' do
visit project_releases_path(project) visit project_releases_path(project)
page.within('.js-assets-list') do page.within('.js-assets-list') do
expect(page).to have_link release_link.name, href: direct_asset_link expect(page).to have_link release_link.name, href: direct_asset_link
expect(page).not_to have_content('(external source)') expect(page).not_to have_css('[data-testid="external-link-indicator"]')
end end
end end
end
context 'when url points to external resource' do context 'when there is a link redirect' do
let(:url) { 'http://google.com/download' } let!(:release_link) { create(:release_link, release: release, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: url) }
let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
it 'sees that the link is external resource' do it 'sees the link' do
visit project_releases_path(project) visit project_releases_path(project)
page.within('.js-assets-list') do page.within('.js-assets-list') do
expect(page).to have_content('(external source)') expect(page).to have_link release_link.name, href: direct_asset_link
expect(page).not_to have_css('[data-testid="external-link-indicator"]')
end
end
end
context 'when url points to external resource' do
let(:url) { 'http://google.com/download' }
it 'sees that the link is external resource' do
visit project_releases_path(project)
page.within('.js-assets-list') do
expect(page).to have_css('[data-testid="external-link-indicator"]')
end
end end
end end
end end
end end
context 'when the release_asset_link_type feature flag is enabled' do
before do
stub_feature_flags(release_asset_link_type: true)
end
it_behaves_like 'asset link tests'
end
context 'when the release_asset_link_type feature flag is disabled' do
before do
stub_feature_flags(release_asset_link_type: false)
end
it_behaves_like 'asset link tests'
end
context 'with an upcoming release' do context 'with an upcoming release' do
let(:tomorrow) { Time.zone.now + 1.day } let(:tomorrow) { Time.zone.now + 1.day }
let!(:release) { create(:release, project: project, released_at: tomorrow ) } let!(:release) { create(:release, project: project, released_at: tomorrow ) }
......
import { mount } from '@vue/test-utils';
import { GlCollapse } from '@gitlab/ui';
import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue';
import { ASSET_LINK_TYPE } from '~/releases/constants';
import { trimText } from 'helpers/text_helper';
import { assets } from '../mock_data';
describe('Release block assets', () => {
let wrapper;
let defaultProps;
// A map of types to the expected section heading text
const sections = {
[ASSET_LINK_TYPE.IMAGE]: 'Images',
[ASSET_LINK_TYPE.PACKAGE]: 'Packages',
[ASSET_LINK_TYPE.RUNBOOK]: 'Runbooks',
[ASSET_LINK_TYPE.OTHER]: 'Other',
};
const createComponent = (propsData = defaultProps) => {
wrapper = mount(ReleaseBlockAssets, {
provide: {
glFeatures: { releaseAssetLinkType: true },
},
propsData,
});
};
const findSectionHeading = type =>
wrapper.findAll('h5').filter(h5 => h5.text() === sections[type]);
beforeEach(() => {
defaultProps = { assets };
});
describe('with default props', () => {
beforeEach(() => createComponent());
const findAccordionButton = () => wrapper.find('[data-testid="accordion-button"]');
it('renders an "Assets" accordion with the asset count', () => {
const accordionButton = findAccordionButton();
expect(accordionButton.exists()).toBe(true);
expect(trimText(accordionButton.text())).toBe('Assets 5');
});
it('renders the accordion as expanded by default', () => {
const accordion = wrapper.find(GlCollapse);
expect(accordion.exists()).toBe(true);
expect(accordion.isVisible()).toBe(true);
});
it('renders sources with the expected text and URL', () => {
defaultProps.assets.sources.forEach(s => {
const sourceLink = wrapper.find(`li>a[href="${s.url}"]`);
expect(sourceLink.exists()).toBe(true);
expect(sourceLink.text()).toBe(`Source code (${s.format})`);
});
});
it('renders a heading for each assets type (except sources)', () => {
Object.keys(sections).forEach(type => {
const sectionHeadings = findSectionHeading(type);
expect(sectionHeadings).toHaveLength(1);
});
});
it('renders asset links with the expected text and URL', () => {
defaultProps.assets.links.forEach(l => {
const sourceLink = wrapper.find(`li>a[href="${l.directAssetUrl}"]`);
expect(sourceLink.exists()).toBe(true);
expect(sourceLink.text()).toBe(l.name);
});
});
});
describe("when a release doesn't have a link with a certain asset type", () => {
const typeToExclude = ASSET_LINK_TYPE.IMAGE;
beforeEach(() => {
defaultProps.assets.links = defaultProps.assets.links.filter(
l => l.linkType !== typeToExclude,
);
createComponent(defaultProps);
});
it('does not render a section heading if there are no links of that type', () => {
const sectionHeadings = findSectionHeading(typeToExclude);
expect(sectionHeadings).toHaveLength(0);
});
});
describe('external vs internal links', () => {
const containsExternalSourceIndicator = () =>
wrapper.contains('[data-testid="external-link-indicator"]');
describe('when a link is external', () => {
beforeEach(() => {
defaultProps.assets.sources = [];
defaultProps.assets.links = [
{
...defaultProps.assets.links[0],
external: true,
},
];
createComponent(defaultProps);
});
it('renders the link with an "external source" indicator', () => {
expect(containsExternalSourceIndicator()).toBe(true);
});
});
describe('when a link is internal', () => {
beforeEach(() => {
defaultProps.assets.sources = [];
defaultProps.assets.links = [
{
...defaultProps.assets.links[0],
external: false,
},
];
createComponent(defaultProps);
});
it('renders the link without the "external source" indicator', () => {
expect(containsExternalSourceIndicator()).toBe(false);
});
});
});
});
import { ASSET_LINK_TYPE } from '~/releases/constants';
export const milestones = [ export const milestones = [
{ {
id: 50, id: 50,
...@@ -150,6 +152,42 @@ export const pageInfoHeadersWithPagination = { ...@@ -150,6 +152,42 @@ export const pageInfoHeadersWithPagination = {
'X-TOTAL-PAGES': '2', 'X-TOTAL-PAGES': '2',
}; };
export const assets = {
count: 5,
sources: [
{
format: 'zip',
url: 'https://example.gitlab.com/path/to/zip',
},
],
links: [
{
linkType: ASSET_LINK_TYPE.IMAGE,
url: 'https://example.gitlab.com/path/to/image',
directAssetUrl: 'https://example.gitlab.com/path/to/image',
name: 'Example image link',
},
{
linkType: ASSET_LINK_TYPE.PACKAGE,
url: 'https://example.gitlab.com/path/to/package',
directAssetUrl: 'https://example.gitlab.com/path/to/package',
name: 'Example package link',
},
{
linkType: ASSET_LINK_TYPE.RUNBOOK,
url: 'https://example.gitlab.com/path/to/runbook',
directAssetUrl: 'https://example.gitlab.com/path/to/runbook',
name: 'Example runbook link',
},
{
linkType: ASSET_LINK_TYPE.OTHER,
url: 'https://example.gitlab.com/path/to/link',
directAssetUrl: 'https://example.gitlab.com/path/to/link',
name: 'Example link',
},
],
};
export const release2 = { export const release2 = {
name: 'Bionic Beaver', name: 'Bionic Beaver',
tag_name: '18.04', tag_name: '18.04',
...@@ -180,42 +218,7 @@ export const release2 = { ...@@ -180,42 +218,7 @@ export const release2 = {
committer_email: 'jack@example.com', committer_email: 'jack@example.com',
committed_date: '2012-05-28T04:42:42-07:00', committed_date: '2012-05-28T04:42:42-07:00',
}, },
assets: { assets,
count: 6,
sources: [
{
format: 'zip',
url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/archive/v11.3.12/gitlab-ce-v11.3.12.zip',
},
{
format: 'tar.gz',
url:
'https://gitlab.com/gitlab-org/gitlab-foss/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar.gz',
},
{
format: 'tar.bz2',
url:
'https://gitlab.com/gitlab-org/gitlab-foss/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar.bz2',
},
{
format: 'tar',
url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar',
},
],
links: [
{
name: 'release-18.04.dmg',
url: 'https://my-external-hosting.example.com/scrambled-url/',
external: true,
},
{
name: 'binary-linux-amd64',
url:
'https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
external: false,
},
],
},
}; };
export const releases = [release, release2]; export const releases = [release, release2];
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