Commit dc55a4d5 authored by Nathan Friend's avatar Nathan Friend

Allow scrolling to release blocks via anchor tag

This commit updates the release blocks to allow them to scroll
themselves into view if they detect that the URL hash contains
the name of their associated git tag.
parent c1d17c55
...@@ -6,6 +6,9 @@ import Icon from '~/vue_shared/components/icon.vue'; ...@@ -6,6 +6,9 @@ import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import { __, n__, sprintf } from '../../locale'; 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';
export default { export default {
name: 'ReleaseBlock', name: 'ReleaseBlock',
...@@ -26,7 +29,15 @@ export default { ...@@ -26,7 +29,15 @@ export default {
default: () => ({}), default: () => ({}),
}, },
}, },
data() {
return {
isHighlighted: false,
};
},
computed: { computed: {
id() {
return slugify(this.release.tag_name);
},
releasedTimeAgo() { releasedTimeAgo() {
return sprintf(__('released %{time}'), { return sprintf(__('released %{time}'), {
time: this.timeFormated(this.release.released_at), time: this.timeFormated(this.release.released_at),
...@@ -62,10 +73,21 @@ export default { ...@@ -62,10 +73,21 @@ export default {
return n__('Milestone', 'Milestones', this.release.milestones.length); return n__('Milestone', 'Milestones', this.release.milestones.length);
}, },
}, },
mounted() {
const hash = getLocationHash();
if (hash && slugify(hash) === this.id) {
this.isHighlighted = true;
setTimeout(() => {
this.isHighlighted = false;
}, 2000);
scrollToElement(this.$el);
}
},
}; };
</script> </script>
<template> <template>
<div :id="release.tag_name" class="card"> <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block">
<div class="card-body"> <div class="card-body">
<h2 class="card-title mt-0"> <h2 class="card-title mt-0">
{{ release.name }} {{ release.name }}
......
.release-block {
transition: background-color 1s linear;
}
...@@ -55,6 +55,10 @@ ...@@ -55,6 +55,10 @@
background-color: $gray-light; background-color: $gray-light;
} }
.bg-line-target-blue {
background: $line-target-blue;
}
.text-break-word { .text-break-word {
word-break: break-all; word-break: break-all;
} }
......
---
title: Allow releases to be targeted by URL anchor links on the Releases page
merge_request: 17150
author:
type: added
...@@ -4,6 +4,18 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; ...@@ -4,6 +4,18 @@ import timeagoMixin from '~/vue_shared/mixins/timeago';
import { first } from 'underscore'; import { first } from 'underscore';
import { release } from '../mock_data'; import { release } from '../mock_data';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
let mockLocationHash;
jest.mock('~/lib/utils/url_utility', () => ({
__esModule: true,
getLocationHash: jest.fn().mockImplementation(() => mockLocationHash),
}));
jest.mock('~/lib/utils/common_utils', () => ({
__esModule: true,
scrollToElement: jest.fn(),
}));
describe('Release block', () => { describe('Release block', () => {
let wrapper; let wrapper;
...@@ -159,4 +171,61 @@ describe('Release block', () => { ...@@ -159,4 +171,61 @@ describe('Release block', () => {
expect(wrapper.text()).toContain('Upcoming Release'); 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-');
});
describe('anchor scrolling', () => {
beforeEach(() => {
scrollToElement.mockClear();
});
const hasTargetBlueBackground = () => wrapper.classes('bg-line-target-blue');
it('does not attempt to scroll the page if no anchor tag is included in the URL', () => {
mockLocationHash = '';
factory(release);
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();
});
it("attempts to scroll itself into view if the anchor tag matches the release's tag name", () => {
mockLocationHash = release.tag_name;
factory(release);
expect(scrollToElement).toHaveBeenCalledTimes(1);
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(() => {
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(() => {
expect(hasTargetBlueBackground()).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