Commit debb42ef authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '216880-frontend-add-sticky-issue-titles' into 'master'

Add sticky titles on Issue pages

See merge request gitlab-org/gitlab!33983
parents 0eb79ac6 7a3b4eac
<script>
import { GlIcon, 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 eventHub from '../event_hub';
import Service from '../services/index';
import Store from '../stores';
......@@ -12,10 +13,13 @@ 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';
import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants';
export default {
components: {
GlIcon,
GlIntersectionObserver,
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 === IssuableStatus.Open;
},
statusIcon() {
return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
},
statusText() {
return IssuableStatusText[this.issuableStatus];
},
shouldShowStickyHeader() {
return this.isStickyHeaderShowing && this.issuableType === 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,40 @@ export default {
:title-text="state.titleText"
:show-inline-edit-button="showInlineEditButton"
/>
<gl-intersection-observer @appear="hideStickyHeader" @disappear="showStickyHeader">
<transition name="issuable-header-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"
data-testid="issue-sticky-header"
>
<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']"
>
<gl-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 +461,7 @@ export default {
:lock-version="state.lock_version"
@taskListUpdateFailed="updateStoreState"
/>
<edited-component
v-if="hasUpdated"
:updated-at="state.updatedAt"
......
import { __ } from '~/locale';
export const IssuableStatus = {
Open: 'opened',
Closed: 'closed',
};
export const IssuableStatusText = {
[IssuableStatus.Open]: __('Open'),
[IssuableStatus.Closed]: __('Closed'),
};
export const IssuableType = {
Issue: 'issue',
Epic: 'epic',
MergeRequest: 'merge_request',
};
......@@ -304,6 +304,72 @@ ul.related-merge-requests > li {
}
}
.issue-sticky-header {
@include gl-left-0;
@include gl-w-full;
top: $header-height;
// 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-header-slide-enter-active,
.issuable-header-slide-leave-active {
@include gl-transition-slow;
}
.issuable-header-slide-enter,
.issuable-header-slide-leave-to {
transform: translateY(-100%);
}
.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('[data-testid="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