Commit aa8c980c authored by Nathan Friend's avatar Nathan Friend

Add edit button to release blocks

This commit adds an "edit" button to each release block on the Releases
page that links to the "Edit release" page.
parent 915aa1ac
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import _ from 'underscore';
import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
......@@ -9,19 +9,21 @@ import { __, n__, sprintf } from '~/locale';
import { slugify } from '~/lib/utils/text_utility';
import { getLocationHash } from '~/lib/utils/url_utility';
import { scrollToElement } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'ReleaseBlock',
components: {
GlLink,
GlBadge,
GlButton,
Icon,
UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
mixins: [timeagoMixin, glFeatureFlagsMixin()],
props: {
release: {
type: Object,
......@@ -72,6 +74,11 @@ export default {
labelText() {
return n__('Milestone', 'Milestones', this.release.milestones.length);
},
shouldShowEditButton() {
return Boolean(
this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit,
);
},
},
mounted() {
const hash = getLocationHash();
......@@ -89,12 +96,23 @@ export default {
<template>
<div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block">
<div class="card-body">
<h2 class="card-title mt-0">
{{ release.name }}
<gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
__('Upcoming Release')
}}</gl-badge>
</h2>
<div class="d-flex align-items-start">
<h2 class="card-title mt-0 mr-auto">
{{ release.name }}
<gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
__('Upcoming Release')
}}</gl-badge>
</h2>
<gl-link
v-if="shouldShowEditButton"
v-gl-tooltip
class="btn btn-default js-edit-button ml-2"
:title="__('Edit this release')"
:href="release._links.edit"
>
<icon name="pencil" />
</gl-link>
</div>
<div class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8">
......
......@@ -4,6 +4,9 @@ class Projects::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :authorize_read_release!
before_action do
push_frontend_feature_flag(:release_edit_page, project)
end
def index
end
......
---
title: Add edit button to release blocks on Releases page
merge_request: 18411
author:
type: added
......@@ -5781,6 +5781,9 @@ msgstr ""
msgid "Edit stage"
msgstr ""
msgid "Edit this release"
msgstr ""
msgid "Edit wiki page"
msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Release block with default props matches the snapshot 1`] = `
<div
class="card release-block"
id="v0.3"
>
<div
class="card-body"
>
<div
class="d-flex align-items-start"
>
<h2
class="card-title mt-0 mr-auto"
>
New release
<!---->
</h2>
<a
class="btn btn-default js-edit-button ml-2"
data-original-title="Edit this release"
href="http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit"
title=""
>
<svg
aria-hidden="true"
class="s16 ic-pencil"
>
<use
xlink:href="#pencil"
/>
</svg>
</a>
</div>
<div
class="card-subtitle d-flex flex-wrap text-secondary"
>
<div
class="append-right-8"
>
<svg
aria-hidden="true"
class="align-middle s16 ic-commit"
>
<use
xlink:href="#commit"
/>
</svg>
<span
data-original-title="Initial commit"
title=""
>
c22b0728
</span>
</div>
<div
class="append-right-8"
>
<svg
aria-hidden="true"
class="align-middle s16 ic-tag"
>
<use
xlink:href="#tag"
/>
</svg>
<span
data-original-title="Tag"
title=""
>
v0.3
</span>
</div>
<div
class="js-milestone-list-label"
>
<svg
aria-hidden="true"
class="align-middle s16 ic-flag"
>
<use
xlink:href="#flag"
/>
</svg>
<span
class="js-label-text"
>
Milestones
</span>
</div>
<a
class="append-right-4 prepend-left-4 js-milestone-link"
data-original-title="The 13.6 milestone!"
href="http://0.0.0.0:3001/root/release-test/-/milestones/2"
title=""
>
13.6
</a>
<a
class="append-right-4 prepend-left-4 js-milestone-link"
data-original-title="The 13.5 milestone!"
href="http://0.0.0.0:3001/root/release-test/-/milestones/1"
title=""
>
13.5
</a>
<!---->
<div
class="append-right-4"
>
<span
data-original-title="Aug 26, 2019 5:54pm GMT+0000"
title=""
>
released 1 month ago
</span>
</div>
<div
class="d-flex"
>
by
<a
class="user-avatar-link prepend-left-4"
href=""
>
<span>
<img
alt="root's avatar"
class="avatar s20 "
data-original-title=""
data-src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
height="20"
src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
title=""
width="20"
/>
<div
aria-hidden="true"
class="js-user-avatar-image-toolip d-none"
style="display: none;"
>
<div>
root
</div>
</div>
</span>
<!---->
</a>
</div>
</div>
<div
class="card-text prepend-top-default"
>
<b>
Assets
<span
class="js-assets-count badge badge-pill"
>
5
</span>
</b>
<ul
class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"
>
<li
class="append-bottom-8"
>
<a
class=""
data-original-title="Download asset"
href="https://google.com"
title=""
>
<svg
aria-hidden="true"
class="align-middle append-right-4 align-text-bottom s16 ic-package"
>
<use
xlink:href="#package"
/>
</svg>
my link
<span>
(external source)
</span>
</a>
</li>
<li
class="append-bottom-8"
>
<a
class=""
data-original-title="Download asset"
href="https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50"
title=""
>
<svg
aria-hidden="true"
class="align-middle append-right-4 align-text-bottom s16 ic-package"
>
<use
xlink:href="#package"
/>
</svg>
my second link
<!---->
</a>
</li>
</ul>
<div
class="dropdown"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="btn btn-link"
data-toggle="dropdown"
type="button"
>
<svg
aria-hidden="true"
class="align-top append-right-4 s16 ic-doc-code"
>
<use
xlink:href="#doc-code"
/>
</svg>
Source code
<svg
aria-hidden="true"
class="s16 ic-arrow-down"
>
<use
xlink:href="#arrow-down"
/>
</svg>
</button>
<div
class="js-sources-dropdown dropdown-menu"
>
<li>
<a
class=""
href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip"
>
Download zip
</a>
</li>
<li>
<a
class=""
href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz"
>
Download tar.gz
</a>
</li>
<li>
<a
class=""
href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2"
>
Download tar.bz2
</a>
</li>
<li>
<a
class=""
href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar"
>
Download tar
</a>
</li>
</div>
</div>
</div>
<div
class="card-text prepend-top-default"
>
<div>
<p
data-sourcepos="1:1-1:21"
dir="auto"
>
A super nice release!
</p>
</div>
</div>
</div>
</div>
`;
......@@ -19,46 +19,53 @@ jest.mock('~/lib/utils/common_utils', () => ({
describe('Release block', () => {
let wrapper;
let releaseClone;
const factory = releaseProp => {
const factory = (releaseProp, releaseEditPageFeatureFlag = true) => {
wrapper = mount(ReleaseBlock, {
propsData: {
release: releaseProp,
},
provide: {
glFeatures: {
releaseEditPage: releaseEditPageFeatureFlag,
},
},
sync: false,
});
return wrapper.vm.$nextTick();
};
const milestoneListLabel = () => wrapper.find('.js-milestone-list-label');
const editButton = () => wrapper.find('.js-edit-button');
beforeEach(() => {
releaseClone = JSON.parse(JSON.stringify(release));
});
afterEach(() => {
wrapper.destroy();
});
describe('with default props', () => {
beforeEach(() => {
factory(release);
beforeEach(() => factory(release));
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it("renders the block with an id equal to the release's tag name", () => {
expect(wrapper.attributes().id).toBe('v0.3');
});
it('renders release name', () => {
expect(wrapper.text()).toContain(release.name);
});
it('renders commit sha', () => {
expect(wrapper.text()).toContain(release.commit.short_id);
wrapper.setProps({ release: { ...release, commit_path: '/commit/example' } });
expect(wrapper.find('a[href="/commit/example"]').exists()).toBe(true);
it('renders an edit button that links to the "Edit release" page', () => {
expect(editButton().exists()).toBe(true);
expect(editButton().attributes('href')).toBe(release._links.edit);
});
it('renders tag name', () => {
expect(wrapper.text()).toContain(release.tag_name);
wrapper.setProps({ release: { ...release, tag_path: '/tag/example' } });
expect(wrapper.find('a[href="/tag/example"]').exists()).toBe(true);
it('renders release name', () => {
expect(wrapper.text()).toContain(release.name);
});
it('renders release date', () => {
......@@ -141,44 +148,73 @@ describe('Release block', () => {
});
});
it('renders commit sha', () => {
releaseClone.commit_path = '/commit/example';
return factory(releaseClone).then(() => {
expect(wrapper.text()).toContain(release.commit.short_id);
expect(wrapper.find('a[href="/commit/example"]').exists()).toBe(true);
});
});
it('renders tag name', () => {
releaseClone.tag_path = '/tag/example';
return factory(releaseClone).then(() => {
expect(wrapper.text()).toContain(release.tag_name);
expect(wrapper.find('a[href="/tag/example"]').exists()).toBe(true);
});
});
it("does not render an edit button if release._links.edit isn't a string", () => {
delete releaseClone._links;
return factory(releaseClone).then(() => {
expect(editButton().exists()).toBe(false);
});
});
it('does not render an edit button if the releaseEditPage feature flag is disabled', () =>
factory(releaseClone, false).then(() => {
expect(editButton().exists()).toBe(false);
}));
it('does not render the milestone list if no milestones are associated to the release', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
delete releaseClone.milestones;
factory(releaseClone);
expect(milestoneListLabel().exists()).toBe(false);
return factory(releaseClone).then(() => {
expect(milestoneListLabel().exists()).toBe(false);
});
});
it('renders the label as "Milestone" if only a single milestone is passed in', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
releaseClone.milestones = releaseClone.milestones.slice(0, 1);
factory(releaseClone);
expect(
milestoneListLabel()
.find('.js-label-text')
.text(),
).toEqual('Milestone');
return factory(releaseClone).then(() => {
expect(
milestoneListLabel()
.find('.js-label-text')
.text(),
).toEqual('Milestone');
});
});
it('renders upcoming release badge', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
releaseClone.upcoming_release = true;
factory(releaseClone);
expect(wrapper.text()).toContain('Upcoming Release');
return factory(releaseClone).then(() => {
expect(wrapper.text()).toContain('Upcoming Release');
});
});
it('slugifies the tag_name before setting it as the elements ID', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
releaseClone.tag_name = 'a dangerous tag name <script>alert("hello")</script>';
factory(releaseClone);
expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script-');
return factory(releaseClone).then(() => {
expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script-');
});
});
describe('anchor scrolling', () => {
......@@ -190,40 +226,39 @@ describe('Release block', () => {
it('does not attempt to scroll the page if no anchor tag is included in the URL', () => {
mockLocationHash = '';
factory(release);
expect(scrollToElement).not.toHaveBeenCalled();
return factory(release).then(() => {
expect(scrollToElement).not.toHaveBeenCalled();
});
});
it("does not attempt to scroll the page if the anchor tag doesn't match the release's tag name", () => {
mockLocationHash = 'v0.4';
factory(release);
expect(scrollToElement).not.toHaveBeenCalled();
return factory(release).then(() => {
expect(scrollToElement).not.toHaveBeenCalled();
});
});
it("attempts to scroll itself into view if the anchor tag matches the release's tag name", () => {
mockLocationHash = release.tag_name;
factory(release);
return factory(release).then(() => {
expect(scrollToElement).toHaveBeenCalledTimes(1);
expect(scrollToElement).toHaveBeenCalledTimes(1);
expect(scrollToElement).toHaveBeenCalledWith(wrapper.element);
expect(scrollToElement).toHaveBeenCalledWith(wrapper.element);
});
});
it('renders with a light blue background if it is the target of the anchor', () => {
mockLocationHash = release.tag_name;
factory(release);
return wrapper.vm.$nextTick().then(() => {
return factory(release).then(() => {
expect(hasTargetBlueBackground()).toBe(true);
});
});
it('does not render with a light blue background if it is not the target of the anchor', () => {
mockLocationHash = '';
factory(release);
return wrapper.vm.$nextTick().then(() => {
return factory(release).then(() => {
expect(hasTargetBlueBackground()).toBe(false);
});
});
......
......@@ -94,4 +94,7 @@ export const release = {
},
],
},
_links: {
edit: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit',
},
};
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