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> <script>
import { GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { visitUrl } from '../../lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import Poll from '../../lib/utils/poll'; import Poll from '~/lib/utils/poll';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import Service from '../services/index'; import Service from '../services/index';
import Store from '../stores'; import Store from '../stores';
...@@ -12,10 +13,13 @@ import descriptionComponent from './description.vue'; ...@@ -12,10 +13,13 @@ import descriptionComponent from './description.vue';
import editedComponent from './edited.vue'; import editedComponent from './edited.vue';
import formComponent from './form.vue'; import formComponent from './form.vue';
import PinnedLinks from './pinned_links.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 { export default {
components: { components: {
GlIcon,
GlIntersectionObserver,
descriptionComponent, descriptionComponent,
titleComponent, titleComponent,
editedComponent, editedComponent,
...@@ -69,6 +73,11 @@ export default { ...@@ -69,6 +73,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
issuableStatus: {
type: String,
required: false,
default: '',
},
initialTitleHtml: { initialTitleHtml: {
type: String, type: String,
required: true, required: true,
...@@ -162,6 +171,7 @@ export default { ...@@ -162,6 +171,7 @@ export default {
state: store.state, state: store.state,
showForm: false, showForm: false,
templatesRequested: false, templatesRequested: false,
isStickyHeaderShowing: false,
}; };
}, },
computed: { computed: {
...@@ -196,6 +206,18 @@ export default { ...@@ -196,6 +206,18 @@ export default {
defaultErrorMessage() { defaultErrorMessage() {
return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); 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() { created() {
this.service = new Service(this.endpoint); this.service = new Service(this.endpoint);
...@@ -349,6 +371,14 @@ export default { ...@@ -349,6 +371,14 @@ export default {
); );
}); });
}, },
hideStickyHeader() {
this.isStickyHeaderShowing = false;
},
showStickyHeader() {
this.isStickyHeaderShowing = true;
},
}, },
}; };
</script> </script>
...@@ -385,10 +415,40 @@ export default { ...@@ -385,10 +415,40 @@ export default {
:title-text="state.titleText" :title-text="state.titleText"
:show-inline-edit-button="showInlineEditButton" :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 <pinned-links
:zoom-meeting-url="zoomMeetingUrl" :zoom-meeting-url="zoomMeetingUrl"
:published-incident-url="publishedIncidentUrl" :published-incident-url="publishedIncidentUrl"
/> />
<description-component <description-component
v-if="state.descriptionHtml" v-if="state.descriptionHtml"
:can-update="canUpdate" :can-update="canUpdate"
...@@ -401,6 +461,7 @@ export default { ...@@ -401,6 +461,7 @@ export default {
:lock-version="state.lock_version" :lock-version="state.lock_version"
@taskListUpdateFailed="updateStoreState" @taskListUpdateFailed="updateStoreState"
/> />
<edited-component <edited-component
v-if="hasUpdated" v-if="hasUpdated"
:updated-at="state.updatedAt" :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 { ...@@ -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 { .issuable-list-root {
.gl-label-link { .gl-label-link {
text-decoration: none; text-decoration: none;
......
...@@ -276,6 +276,7 @@ module IssuablesHelper ...@@ -276,6 +276,7 @@ module IssuablesHelper
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable), canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable), canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference, issuableRef: issuable.to_reference,
issuableStatus: issuable.state,
markdownPreviewPath: preview_markdown_path(parent), markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'), markdownDocsPath: help_page_path('user/markdown'),
lockVersion: issuable.lock_version, lockVersion: issuable.lock_version,
......
---
title: Add sticky title on Issue pages
merge_request: 33983
author:
type: added
...@@ -15,6 +15,11 @@ describe('EpicAppComponent', () => { ...@@ -15,6 +15,11 @@ describe('EpicAppComponent', () => {
let mock; let mock;
beforeEach(() => { beforeEach(() => {
window.IntersectionObserver = class {
disconnect = jest.fn();
observe = jest.fn();
};
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(`${TEST_HOST}/realtime_changes`).reply(200, initialRequest); mock.onGet(`${TEST_HOST}/realtime_changes`).reply(200, initialRequest);
...@@ -29,6 +34,7 @@ describe('EpicAppComponent', () => { ...@@ -29,6 +34,7 @@ describe('EpicAppComponent', () => {
}); });
afterEach(() => { afterEach(() => {
delete window.IntersectionObserver;
mock.restore(); mock.restore();
vm.$destroy(); vm.$destroy();
}); });
......
...@@ -15,6 +15,11 @@ describe('EpicBodyComponent', () => { ...@@ -15,6 +15,11 @@ describe('EpicBodyComponent', () => {
let mock; let mock;
beforeEach(() => { beforeEach(() => {
window.IntersectionObserver = class {
disconnect = jest.fn();
observe = jest.fn();
};
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(`${TEST_HOST}/realtime_changes`).reply(200, initialRequest); mock.onGet(`${TEST_HOST}/realtime_changes`).reply(200, initialRequest);
...@@ -29,6 +34,7 @@ describe('EpicBodyComponent', () => { ...@@ -29,6 +34,7 @@ describe('EpicBodyComponent', () => {
}); });
afterEach(() => { afterEach(() => {
delete window.IntersectionObserver;
mock.restore(); mock.restore();
vm.$destroy(); vm.$destroy();
}); });
......
...@@ -26,6 +26,7 @@ RSpec.describe IssuablesHelper do ...@@ -26,6 +26,7 @@ RSpec.describe IssuablesHelper do
canDestroy: true, canDestroy: true,
canAdmin: true, canAdmin: true,
issuableRef: "&#{epic.iid}", issuableRef: "&#{epic.iid}",
issuableStatus: "opened",
markdownPreviewPath: "/groups/#{@group.full_path}/preview_markdown", markdownPreviewPath: "/groups/#{@group.full_path}/preview_markdown",
markdownDocsPath: '/help/user/markdown', markdownDocsPath: '/help/user/markdown',
issuableTemplateNamesPath: '', issuableTemplateNamesPath: '',
......
import { GlIntersectionObserver } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
...@@ -25,6 +26,8 @@ describe('Issuable output', () => { ...@@ -25,6 +26,8 @@ describe('Issuable output', () => {
let realtimeRequestCount = 0; let realtimeRequestCount = 0;
let wrapper; let wrapper;
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
beforeEach(() => { beforeEach(() => {
setFixtures(` setFixtures(`
<div> <div>
...@@ -42,6 +45,11 @@ describe('Issuable output', () => { ...@@ -42,6 +45,11 @@ describe('Issuable output', () => {
</div> </div>
`); `);
window.IntersectionObserver = class {
disconnect = jest.fn();
observe = jest.fn();
};
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock mock
.onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes') .onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes')
...@@ -58,6 +66,7 @@ describe('Issuable output', () => { ...@@ -58,6 +66,7 @@ describe('Issuable output', () => {
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST, updateEndpoint: TEST_HOST,
issuableRef: '#1', issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '', initialTitleHtml: '',
initialTitleText: '', initialTitleText: '',
initialDescriptionHtml: 'test', initialDescriptionHtml: 'test',
...@@ -75,6 +84,7 @@ describe('Issuable output', () => { ...@@ -75,6 +84,7 @@ describe('Issuable output', () => {
}); });
afterEach(() => { afterEach(() => {
delete window.IntersectionObserver;
mock.restore(); mock.restore();
realtimeRequestCount = 0; realtimeRequestCount = 0;
...@@ -520,4 +530,39 @@ describe('Issuable output', () => { ...@@ -520,4 +530,39 @@ describe('Issuable output', () => {
expect(wrapper.vm.issueChanged).toBe(false); 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