Commit 3ca6eda9 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch 'nfriend-show-asset-type-on-releases-page' into 'master'

Show link asset types on Releases page

Closes #208795

See merge request gitlab-org/gitlab!33643
parents 03d106ed 4aaaa813
<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 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 {
name: 'ReleaseBlockAssets',
components: {
GlLink,
GlButton,
GlCollapse,
GlIcon,
Icon,
GlBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
assets: {
type: Object,
required: true,
},
},
data() {
return {
isAssetsExpanded: true,
};
},
computed: {
hasAssets() {
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>
<template>
<div class="card-text prepend-top-default">
<b>
{{ __('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">{{ __('(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"
<template v-if="glFeatures.releaseAssetLinkType">
<gl-button
data-testid="accordion-button"
variant="link"
class="gl-font-weight-bold"
@click="toggleAssetsExpansion"
>
<icon name="doc-code" class="align-top append-right-4" />
{{ __('Source code') }}
<icon name="chevron-down" />
</button>
<gl-icon
name="chevron-right"
class="gl-transition-medium"
: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">
<li v-for="asset in assets.sources" :key="asset.url">
<gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
<template v-else>
<b>
{{ __('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>
</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>
</template>
</div>
</template>
......@@ -18341,15 +18341,24 @@ msgstr ""
msgid "ReleaseAssetLinkType|Image"
msgstr ""
msgid "ReleaseAssetLinkType|Images"
msgstr ""
msgid "ReleaseAssetLinkType|Other"
msgstr ""
msgid "ReleaseAssetLinkType|Package"
msgstr ""
msgid "ReleaseAssetLinkType|Packages"
msgstr ""
msgid "ReleaseAssetLinkType|Runbook"
msgstr ""
msgid "ReleaseAssetLinkType|Runbooks"
msgstr ""
msgid "Releases"
msgstr ""
......@@ -20992,6 +21001,9 @@ msgstr ""
msgid "Source code"
msgstr ""
msgid "Source code (%{fileExtension})"
msgstr ""
msgid "Source is not available"
msgstr ""
......@@ -22894,6 +22906,9 @@ msgstr ""
msgid "This license has already expired."
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."
msgstr ""
......
......@@ -26,47 +26,65 @@ RSpec.describe 'User views releases', :js do
expect(page).not_to have_content('Upcoming Release')
end
context 'when there is a link as an asset' do
let!(:release_link) { create(:release_link, release: release, url: url ) }
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
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) }
shared_examples 'asset link tests' do
context 'when there is a link as an asset' do
let!(:release_link) { create(:release_link, release: release, url: url ) }
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
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)')
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' }
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" }
it 'sees that the link is external resource' do
visit project_releases_path(project)
it 'sees the link' do
visit project_releases_path(project)
page.within('.js-assets-list') do
expect(page).to have_content('(external source)')
page.within('.js-assets-list') do
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
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
let(:tomorrow) { Time.zone.now + 1.day }
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 = [
{
id: 50,
......@@ -150,6 +152,42 @@ export const pageInfoHeadersWithPagination = {
'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 = {
name: 'Bionic Beaver',
tag_name: '18.04',
......@@ -180,42 +218,7 @@ export const release2 = {
committer_email: 'jack@example.com',
committed_date: '2012-05-28T04:42:42-07:00',
},
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,
},
],
},
assets,
};
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