Commit a1d6c24e authored by Coung Ngo's avatar Coung Ngo

Add sticky titles on Issue pages

Sticky titles were added so users can easily see the issue
title when scrolled deep into an issue to stay in context
parent 01e9d175
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import { __, s__, sprintf } from '~/locale';
import createFlash from '~/flash';
import { visitUrl } from '../../lib/utils/url_utility';
import Poll from '../../lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
import Poll from '~/lib/utils/poll';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import Service from '../services/index';
import Store from '../stores';
......@@ -12,10 +14,12 @@ import descriptionComponent from './description.vue';
import editedComponent from './edited.vue';
import formComponent from './form.vue';
import PinnedLinks from './pinned_links.vue';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
export default {
components: {
GlIntersectionObserver,
Icon,
descriptionComponent,
titleComponent,
editedComponent,
......@@ -69,6 +73,11 @@ export default {
type: String,
required: true,
},
issuableStatus: {
type: String,
required: false,
default: '',
},
initialTitleHtml: {
type: String,
required: true,
......@@ -162,6 +171,7 @@ export default {
state: store.state,
showForm: false,
templatesRequested: false,
isStickyHeaderShowing: false,
};
},
computed: {
......@@ -196,6 +206,18 @@ export default {
defaultErrorMessage() {
return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
},
isOpenStatus() {
return this.issuableStatus === 'opened';
},
statusIcon() {
return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
},
statusText() {
return this.isOpenStatus ? __('Open') : __('Closed');
},
shouldShowStickyHeader() {
return this.isStickyHeaderShowing && this.issuableType === 'issue';
},
},
created() {
this.service = new Service(this.endpoint);
......@@ -349,6 +371,14 @@ export default {
);
});
},
hideStickyHeader() {
this.isStickyHeaderShowing = false;
},
showStickyHeader() {
this.isStickyHeaderShowing = true;
},
},
};
</script>
......@@ -385,10 +415,39 @@ export default {
:title-text="state.titleText"
:show-inline-edit-button="showInlineEditButton"
/>
<gl-intersection-observer @appear="hideStickyHeader" @disappear="showStickyHeader">
<transition name="slide">
<div
v-if="shouldShowStickyHeader"
class="issue-sticky-header gl-fixed gl-z-index-2 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-200 gl-py-3"
>
<div
class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
>
<p
class="issuable-status-box status-box gl-my-0"
:class="[isOpenStatus ? 'status-box-open' : 'status-box-issue-closed']"
>
<icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
<span class="gl-display-none d-sm-block">{{ statusText }}</span>
</p>
<p
class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
:title="state.titleText"
>
{{ state.titleText }}
</p>
</div>
</div>
</transition>
</gl-intersection-observer>
<pinned-links
:zoom-meeting-url="zoomMeetingUrl"
:published-incident-url="publishedIncidentUrl"
/>
<description-component
v-if="state.descriptionHtml"
:can-update="canUpdate"
......@@ -401,6 +460,7 @@ export default {
:lock-version="state.lock_version"
@taskListUpdateFailed="updateStoreState"
/>
<edited-component
v-if="hasUpdated"
:updated-at="state.updatedAt"
......@@ -410,3 +470,15 @@ export default {
</div>
</div>
</template>
<style>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.5s;
}
.slide-enter,
.slide-leave-to {
transform: translateY(-100%);
}
</style>
......@@ -304,6 +304,62 @@ ul.related-merge-requests > li {
}
}
.issue-sticky-header {
left: 0;
top: $header-height;
width: 100%;
// collapsed right sidebar
@include media-breakpoint-up(sm) {
width: calc(100% - #{$gutter-collapsed-width});
}
.issue-sticky-header-text {
max-width: $limited-layout-width;
}
}
.with-performance-bar .issue-sticky-header {
top: $header-height + $performance-bar-height;
}
@include media-breakpoint-up(md) {
// collapsed left sidebar + collapsed right sidebar
.issue-sticky-header {
left: $contextual-sidebar-collapsed-width;
width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
}
// collapsed left sidebar + expanded right sidebar
.right-sidebar-expanded .issue-sticky-header {
width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
}
}
@include media-breakpoint-up(xl) {
// expanded left sidebar + collapsed right sidebar
.issue-sticky-header {
left: $contextual-sidebar-width;
width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width});
}
// collapsed left sidebar + collapsed right sidebar
.page-with-icon-sidebar .issue-sticky-header {
left: $contextual-sidebar-collapsed-width;
width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
}
// expanded left sidebar + expanded right sidebar
.right-sidebar-expanded .issue-sticky-header {
width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width});
}
// collapsed left sidebar + expanded right sidebar
.right-sidebar-expanded.page-with-icon-sidebar .issue-sticky-header {
width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
}
}
.issuable-list-root {
.gl-label-link {
text-decoration: none;
......
......@@ -276,6 +276,7 @@ module IssuablesHelper
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,
issuableStatus: issuable.state,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
lockVersion: issuable.lock_version,
......
---
title: Add sticky title on Issue pages
merge_request: 33983
author:
type: added
......@@ -15,6 +15,11 @@ describe('EpicAppComponent', () => {
let mock;
beforeEach(() => {
window.IntersectionObserver = class {
disconnect = jest.fn();
observe = jest.fn();
};
mock = new MockAdapter(axios);
mock.onGet(`${TEST_HOST}/realtime_changes`).reply(200, initialRequest);
......@@ -29,6 +34,7 @@ describe('EpicAppComponent', () => {
});
afterEach(() => {
delete window.IntersectionObserver;
mock.restore();
vm.$destroy();
});
......
......@@ -15,6 +15,11 @@ describe('EpicBodyComponent', () => {
let mock;
beforeEach(() => {
window.IntersectionObserver = class {
disconnect = jest.fn();
observe = jest.fn();
};
mock = new MockAdapter(axios);
mock.onGet(`${TEST_HOST}/realtime_changes`).reply(200, initialRequest);
......@@ -29,6 +34,7 @@ describe('EpicBodyComponent', () => {
});
afterEach(() => {
delete window.IntersectionObserver;
mock.restore();
vm.$destroy();
});
......
......@@ -26,6 +26,7 @@ RSpec.describe IssuablesHelper do
canDestroy: true,
canAdmin: true,
issuableRef: "&#{epic.iid}",
issuableStatus: "opened",
markdownPreviewPath: "/groups/#{@group.full_path}/preview_markdown",
markdownDocsPath: '/help/user/markdown',
issuableTemplateNamesPath: '',
......
import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
......@@ -25,6 +26,8 @@ describe('Issuable output', () => {
let realtimeRequestCount = 0;
let wrapper;
const findStickyHeader = () => wrapper.find('.issue-sticky-header');
beforeEach(() => {
setFixtures(`
<div>
......@@ -42,6 +45,11 @@ describe('Issuable output', () => {
</div>
`);
window.IntersectionObserver = class {
disconnect = jest.fn();
observe = jest.fn();
};
mock = new MockAdapter(axios);
mock
.onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes')
......@@ -58,6 +66,7 @@ describe('Issuable output', () => {
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
......@@ -75,6 +84,7 @@ describe('Issuable output', () => {
});
afterEach(() => {
delete window.IntersectionObserver;
mock.restore();
realtimeRequestCount = 0;
......@@ -520,4 +530,39 @@ describe('Issuable output', () => {
expect(wrapper.vm.issueChanged).toBe(false);
});
});
describe('sticky header', () => {
describe('when title is in view', () => {
it('is not shown', () => {
expect(wrapper.contains('.issue-sticky-header')).toBe(false);
});
});
describe('when title is not in view', () => {
beforeEach(() => {
wrapper.vm.state.titleText = 'Sticky header title';
wrapper.find(GlIntersectionObserver).vm.$emit('disappear');
});
it('is shown with title', () => {
expect(findStickyHeader().text()).toContain('Sticky header title');
});
it('is shown with Open when status is opened', () => {
wrapper.setProps({ issuableStatus: 'opened' });
return wrapper.vm.$nextTick(() => {
expect(findStickyHeader().text()).toContain('Open');
});
});
it('is shown with Closed when status is closed', () => {
wrapper.setProps({ issuableStatus: 'closed' });
return wrapper.vm.$nextTick(() => {
expect(findStickyHeader().text()).toContain('Closed');
});
});
});
});
});
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