Commit 7129bf3d authored by Peter Hegman's avatar Peter Hegman Committed by Jacques Erasmus

Convert terms of service to Vue and add max height

parent 74c77c75
...@@ -9,6 +9,8 @@ const FLASH_TYPES = { ...@@ -9,6 +9,8 @@ const FLASH_TYPES = {
WARNING: 'warning', WARNING: 'warning',
}; };
const FLASH_CLOSED_EVENT = 'flashClosed';
const getCloseEl = (flashEl) => { const getCloseEl = (flashEl) => {
return flashEl.querySelector('.js-close-icon'); return flashEl.querySelector('.js-close-icon');
}; };
...@@ -26,6 +28,7 @@ const hideFlash = (flashEl, fadeTransition = true) => { ...@@ -26,6 +28,7 @@ const hideFlash = (flashEl, fadeTransition = true) => {
() => { () => {
flashEl.remove(); flashEl.remove();
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT));
if (document.body.classList.contains('flash-shown')) if (document.body.classList.contains('flash-shown'))
document.body.classList.remove('flash-shown'); document.body.classList.remove('flash-shown');
}, },
...@@ -132,4 +135,5 @@ export { ...@@ -132,4 +135,5 @@ export {
hideFlash, hideFlash,
removeFlashClickListener, removeFlashClickListener,
FLASH_TYPES, FLASH_TYPES,
FLASH_CLOSED_EVENT,
}; };
import { initTermsApp } from '~/terms';
import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
waitForCSSLoaded(initTermsApp);
<script>
import $ from 'jquery';
import { GlButton, GlIntersectionObserver, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import csrf from '~/lib/utils/csrf';
import '~/behaviors/markdown/render_gfm';
export default {
name: 'TermsApp',
i18n: {
accept: __('Accept terms'),
continue: __('Continue'),
decline: __('Decline and sign out'),
},
flashElements: [],
csrf,
directives: {
SafeHtml,
},
components: { GlButton, GlIntersectionObserver },
inject: ['terms', 'permissions', 'paths'],
data() {
return {
acceptDisabled: true,
};
},
computed: {
isLoggedIn,
},
mounted() {
this.renderGFM();
this.setScrollableViewportHeight();
this.$options.flashElements = [
...document.querySelectorAll(
Object.values(FLASH_TYPES)
.map((flashType) => `.flash-${flashType}`)
.join(','),
),
];
this.$options.flashElements.forEach((flashElement) => {
flashElement.addEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
});
},
beforeDestroy() {
this.$options.flashElements.forEach((flashElement) => {
flashElement.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
});
},
methods: {
renderGFM() {
$(this.$refs.gfmContainer).renderGFM();
},
handleBottomReached() {
this.acceptDisabled = false;
},
setScrollableViewportHeight() {
// Reset `max-height` inline style
this.$refs.scrollableViewport.style.maxHeight = '';
const { scrollHeight, clientHeight } = document.documentElement;
// Set `max-height` to 100vh minus all elements that are NOT the scrollable viewport (header, footer, alerts, etc)
this.$refs.scrollableViewport.style.maxHeight = `calc(100vh - ${
scrollHeight - clientHeight
}px)`;
},
handleFlashClose(event) {
this.setScrollableViewportHeight();
event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
},
},
};
</script>
<template>
<div>
<div class="gl-card-body gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content">
<div
class="terms-fade gl-absolute gl-left-5 gl-right-5 gl-bottom-0 gl-h-11 gl-pointer-events-none"
></div>
<div
ref="scrollableViewport"
data-testid="scrollable-viewport"
class="gl-h-100vh gl-overflow-y-auto gl-pb-11 gl-px-5"
>
<div ref="gfmContainer" v-safe-html="terms"></div>
<gl-intersection-observer @appear="handleBottomReached">
<div></div>
</gl-intersection-observer>
</div>
</div>
<div v-if="isLoggedIn" class="gl-card-footer gl-display-flex gl-justify-content-end">
<form v-if="permissions.canDecline" method="post" :action="paths.decline">
<gl-button type="submit">{{ $options.i18n.decline }}</gl-button>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
</form>
<form v-if="permissions.canAccept" class="gl-ml-3" method="post" :action="paths.accept">
<gl-button
type="submit"
variant="confirm"
:disabled="acceptDisabled"
data-qa-selector="accept_terms_button"
>{{ $options.i18n.accept }}</gl-button
>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
</form>
<gl-button v-else class="gl-ml-3" :href="paths.root" variant="confirm">{{
$options.i18n.continue
}}</gl-button>
</div>
</div>
</template>
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import TermsApp from './components/app.vue';
export const initTermsApp = () => {
const el = document.getElementById('js-terms-of-service');
if (!el) return false;
const { terms, permissions, paths } = convertObjectPropsToCamelCase(
JSON.parse(el.dataset.termsData),
{ deep: true },
);
return new Vue({
el,
provide: { terms, permissions, paths },
render(createElement) {
return createElement(TermsApp);
},
});
};
...@@ -62,7 +62,6 @@ ...@@ -62,7 +62,6 @@
@import 'framework/sortable'; @import 'framework/sortable';
@import 'framework/ci_variable_list'; @import 'framework/ci_variable_list';
@import 'framework/feature_highlight'; @import 'framework/feature_highlight';
@import 'framework/terms';
@import 'framework/read_more'; @import 'framework/read_more';
@import 'framework/flex_grid'; @import 'framework/flex_grid';
@import 'framework/system_messages'; @import 'framework/system_messages';
......
@import 'mixins_and_variables_and_functions';
.terms { .terms {
.with-system-header &,
.with-system-header.with-performance-bar &,
.with-performance-bar & { .with-performance-bar & {
margin-top: 0; margin-top: 0;
} }
.alert-wrapper { .terms-fade {
min-height: $header-height + $gl-padding; background: linear-gradient(0deg, $white 0%, rgba($white, 0.5) 100%);
} }
.content { .content {
padding-top: $gl-padding; padding-top: $gl-padding;
} }
.card { .gl-card {
.card-header { .gl-card-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
......
# frozen_string_literal: true
module TermsHelper
def terms_data(terms, redirect)
redirect_params = { redirect: redirect } if redirect
{
terms: markdown_field(terms, :terms),
permissions: {
can_accept: can?(current_user, :accept_terms, terms),
can_decline: can?(current_user, :decline_terms, terms)
},
paths: {
accept: accept_term_path(terms, redirect_params),
decline: decline_term_path(terms, redirect_params),
root: root_path
}
}.to_json
end
end
!!! 5 !!! 5
- add_page_specific_style 'page_bundles/terms'
- @hide_breadcrumbs = true - @hide_breadcrumbs = true
%html{ lang: I18n.locale, class: page_class } %html{ lang: I18n.locale, class: page_class }
= render "layouts/head" = render "layouts/head"
%body{ data: { page: body_data_page } } %body{ data: { page: body_data_page } }
.layout-page.terms{ class: page_class } .layout-page.terms{ class: page_class }
.content-wrapper .content-wrapper.gl-pb-5
.mobile-overlay .mobile-overlay
.alert-wrapper .alert-wrapper
= render "layouts/broadcast" = render "layouts/broadcast"
= render 'layouts/header/read_only_banner' = render 'layouts/header/read_only_banner'
= render "layouts/flash", extra_flash_class: 'limit-container-width' = render "layouts/flash"
%div{ class: "#{container_class} limit-container-width" } %div{ class: "#{container_class} limit-container-width" }
.content{ id: "content-body" } .content{ id: "content-body" }
.card .gl-card
.card-header .gl-card-header
= brand_header_logo = brand_header_logo
- logo_text = brand_header_logo_type - logo_text = brand_header_logo_type
- if logo_text.present? - if logo_text.present?
......
- redirect_params = { redirect: @redirect } if @redirect - redirect_params = { redirect: @redirect } if @redirect
- accept_term_link = accept_term_path(@term, redirect_params) - accept_term_link = accept_term_path(@term, redirect_params)
.card-body.rendered-terms{ data: { qa_selector: 'terms_content' } } - if Feature.enabled?(:terms_of_service_vue, current_user, default_enabled: :yaml)
#js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } }
- else
.card-body.rendered-terms{ data: { qa_selector: 'terms_content' } }
= markdown_field(@term, :terms) = markdown_field(@term, :terms)
- if current_user - if current_user
= render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true = render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true
.card-footer.footer-block.clearfix .card-footer.footer-block.clearfix
- if can?(current_user, :accept_terms, @term) - if can?(current_user, :accept_terms, @term)
......
...@@ -255,6 +255,7 @@ module Gitlab ...@@ -255,6 +255,7 @@ module Gitlab
config.assets.precompile << "page_bundles/security_discover.css" config.assets.precompile << "page_bundles/security_discover.css"
config.assets.precompile << "page_bundles/signup.css" config.assets.precompile << "page_bundles/signup.css"
config.assets.precompile << "page_bundles/terminal.css" config.assets.precompile << "page_bundles/terminal.css"
config.assets.precompile << "page_bundles/terms.css"
config.assets.precompile << "page_bundles/todos.css" config.assets.precompile << "page_bundles/todos.css"
config.assets.precompile << "page_bundles/wiki.css" config.assets.precompile << "page_bundles/wiki.css"
config.assets.precompile << "page_bundles/xterm.css" config.assets.precompile << "page_bundles/xterm.css"
......
---
name: terms_of_service_vue
introduced_by_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/343046
milestone: '14.5'
type: development
group: group::access
default_enabled: false
.terms {
.with-system-header &,
.with-system-header.with-performance-bar & {
margin-top: 0;
}
}
...@@ -753,7 +753,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do ...@@ -753,7 +753,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do
end end
end end
context 'when terms are enforced' do context 'when terms are enforced', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
...@@ -802,7 +802,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do ...@@ -802,7 +802,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do
end end
context 'when the user did not enable 2FA' do context 'when the user did not enable 2FA' do
it 'asks to set 2FA before asking to accept the terms', :js do it 'asks to set 2FA before asking to accept the terms' do
expect(authentication_metrics) expect(authentication_metrics)
.to increment(:user_authenticated_counter) .to increment(:user_authenticated_counter)
...@@ -887,7 +887,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do ...@@ -887,7 +887,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do
end end
end end
context 'when the user does not have an email configured', :js do context 'when the user does not have an email configured' do
let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml', email: 'temp-email-for-oauth-user@gitlab.localhost') } let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml', email: 'temp-email-for-oauth-user@gitlab.localhost') }
before do before do
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'Users > Terms' do RSpec.describe 'Users > Terms', :js do
include TermsHelper include TermsHelper
let!(:term) { create(:term, terms: 'By accepting, you promise to be nice!') } let!(:term) { create(:term, terms: 'By accepting, you promise to be nice!') }
......
...@@ -3,6 +3,7 @@ import createFlash, { ...@@ -3,6 +3,7 @@ import createFlash, {
createAction, createAction,
hideFlash, hideFlash,
removeFlashClickListener, removeFlashClickListener,
FLASH_CLOSED_EVENT,
} from '~/flash'; } from '~/flash';
describe('Flash', () => { describe('Flash', () => {
...@@ -79,6 +80,16 @@ describe('Flash', () => { ...@@ -79,6 +80,16 @@ describe('Flash', () => {
expect(el.remove.mock.calls.length).toBe(1); expect(el.remove.mock.calls.length).toBe(1);
}); });
it(`dispatches ${FLASH_CLOSED_EVENT} event after transitionend event`, () => {
jest.spyOn(el, 'dispatchEvent');
hideFlash(el);
el.dispatchEvent(new Event('transitionend'));
expect(el.dispatchEvent).toHaveBeenCalledWith(new Event(FLASH_CLOSED_EVENT));
});
}); });
describe('createAction', () => { describe('createAction', () => {
......
import $ from 'jquery';
import { merge } from 'lodash';
import { GlIntersectionObserver } from '@gitlab/ui';
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash';
import { isLoggedIn } from '~/lib/utils/common_utils';
import TermsApp from '~/terms/components/app.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
jest.mock('~/lib/utils/common_utils');
describe('TermsApp', () => {
let wrapper;
let renderGFMSpy;
const defaultProvide = {
terms: 'foo bar',
paths: {
accept: '/-/users/terms/1/accept',
decline: '/-/users/terms/1/decline',
root: '/',
},
permissions: {
canAccept: true,
canDecline: true,
},
};
const createComponent = (provide = {}) => {
wrapper = mountExtended(TermsApp, {
provide: merge({}, defaultProvide, provide),
});
};
beforeEach(() => {
renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
isLoggedIn.mockReturnValue(true);
});
afterEach(() => {
wrapper.destroy();
});
const findFormWithAction = (path) => wrapper.find(`form[action="${path}"]`);
const findButton = (path) => findFormWithAction(path).find('button[type="submit"]');
const findScrollableViewport = () => wrapper.findByTestId('scrollable-viewport');
const expectFormWithSubmitButton = (buttonText, path) => {
const form = findFormWithAction(path);
const submitButton = findButton(path);
expect(form.exists()).toBe(true);
expect(submitButton.exists()).toBe(true);
expect(submitButton.text()).toBe(buttonText);
expect(
form
.find('input[type="hidden"][name="authenticity_token"][value="mock-csrf-token"]')
.exists(),
).toBe(true);
};
it('renders terms of service as markdown', () => {
createComponent();
expect(wrapper.findByText(defaultProvide.terms).exists()).toBe(true);
expect(renderGFMSpy).toHaveBeenCalled();
});
describe('accept button', () => {
it('is disabled until user scrolls to the bottom of the terms', async () => {
createComponent();
expect(findButton(defaultProvide.paths.accept).attributes('disabled')).toBe('disabled');
wrapper.find(GlIntersectionObserver).vm.$emit('appear');
await nextTick();
expect(findButton(defaultProvide.paths.accept).attributes('disabled')).toBeUndefined();
});
describe('when user has permissions to accept', () => {
it('renders form and button to accept terms', () => {
createComponent();
expectFormWithSubmitButton(TermsApp.i18n.accept, defaultProvide.paths.accept);
});
});
describe('when user does not have permissions to accept', () => {
it('renders continue button', () => {
createComponent({ permissions: { canAccept: false } });
expect(wrapper.findByText(TermsApp.i18n.continue).exists()).toBe(true);
});
});
});
describe('decline button', () => {
describe('when user has permissions to decline', () => {
it('renders form and button to decline terms', () => {
createComponent();
expectFormWithSubmitButton(TermsApp.i18n.decline, defaultProvide.paths.decline);
});
});
describe('when user does not have permissions to decline', () => {
it('does not render decline button', () => {
createComponent({ permissions: { canDecline: false } });
expect(wrapper.findByText(TermsApp.i18n.decline).exists()).toBe(false);
});
});
});
it('sets height of scrollable viewport', () => {
jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 800);
jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600);
createComponent();
expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 200px);');
});
describe('when flash is closed', () => {
let flashEl;
beforeEach(() => {
flashEl = document.createElement('div');
flashEl.classList.add(`flash-${FLASH_TYPES.ALERT}`);
document.body.appendChild(flashEl);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('recalculates height of scrollable viewport', () => {
jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 800);
jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600);
createComponent();
expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 200px);');
jest.spyOn(document.documentElement, 'scrollHeight', 'get').mockImplementation(() => 700);
jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 600);
flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT));
expect(findScrollableViewport().attributes('style')).toBe('max-height: calc(100vh - 100px);');
});
});
describe('when user is signed out', () => {
beforeEach(() => {
isLoggedIn.mockReturnValue(false);
});
it('does not show any buttons', () => {
createComponent();
expect(wrapper.findByText(TermsApp.i18n.accept).exists()).toBe(false);
expect(wrapper.findByText(TermsApp.i18n.decline).exists()).toBe(false);
expect(wrapper.findByText(TermsApp.i18n.continue).exists()).toBe(false);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe TermsHelper do
let_it_be(:current_user) { build(:user) }
let_it_be(:terms) { build(:term) }
before do
allow(helper).to receive(:current_user).and_return(current_user)
end
describe '#terms_data' do
let_it_be(:redirect) { '%2F' }
let_it_be(:terms_markdown) { 'Lorem ipsum dolor sit amet' }
let_it_be(:accept_path) { '/-/users/terms/14/accept?redirect=%2F' }
let_it_be(:decline_path) { '/-/users/terms/14/decline?redirect=%2F' }
subject(:result) { Gitlab::Json.parse(helper.terms_data(terms, redirect)) }
it 'returns correct json' do
expect(helper).to receive(:markdown_field).with(terms, :terms).and_return(terms_markdown)
expect(helper).to receive(:can?).with(current_user, :accept_terms, terms).and_return(true)
expect(helper).to receive(:can?).with(current_user, :decline_terms, terms).and_return(true)
expect(helper).to receive(:accept_term_path).with(terms, { redirect: redirect }).and_return(accept_path)
expect(helper).to receive(:decline_term_path).with(terms, { redirect: redirect }).and_return(decline_path)
expected = {
terms: terms_markdown,
permissions: {
can_accept: true,
can_decline: true
},
paths: {
accept: accept_path,
decline: decline_path,
root: root_path
}
}.as_json
expect(result).to eq(expected)
end
end
end
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