Commit e5723173 authored by Martin Wortschack's avatar Martin Wortschack Committed by Phil Hughes

Add onboarding helper

- Add tour parts list component
- Add styles for popover
- Add animations for onboarding helper
- Add styles for onboarding helper container
- Update PO files
- Add specs for onboarding helper and tour parts list
parent cf16b3e0
......@@ -39,6 +39,25 @@
}
}
.onboarding-popover {
box-shadow: 0 2px 4px $dropdown-shadow-color;
.popover-body {
font-size: $gl-font-size;
line-height: $gl-line-height;
padding: $gl-padding;
}
.popover-header {
display: none;
}
.accept-mr-label {
background-color: $accepting-mr-label-color;
color: $white-light;
}
}
.onboarding-welcome-page {
.popover {
min-width: auto;
......
......@@ -268,3 +268,27 @@ $skeleton-line-widths: (
@include webkit-prefix(animation-duration, 1s);
transform-origin: 50% 50%;
}
/* ----------------------------------------------
* Generated by Animista on 2019-4-26 17:40:41
* w: http://animista.net, t: @cssanimista
* ---------------------------------------------- */
@keyframes slide-in-fwd-bottom {
0% {
transform: translateZ(-1400px) translateY(800px);
opacity: 0;
}
100% {
transform: translateZ(0) translateY(0);
opacity: 1;
}
}
.slide-in-fwd-bottom-enter-active {
animation: slide-in-fwd-bottom 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
}
.slide-in-fwd-bottom-leave-active {
animation: slide-in-fwd-bottom 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both reverse;
}
......@@ -501,3 +501,38 @@ img.emoji {
}
}
}
.onboarding-helper-container {
bottom: 40px;
right: 40px;
font-size: $gl-font-size-small;
background: $gray-100;
width: 200px;
border-radius: 24px;
box-shadow: 0 2px 4px $issue-boards-card-shadow;
z-index: 10000;
.collapsible {
max-height: 0;
transition: max-height 0.5s cubic-bezier(0, 1, 0, 1);
}
&.expanded {
border-bottom-right-radius: $border-radius-default;
border-bottom-left-radius: $border-radius-default;
.collapsible {
max-height: 1000px;
transition: max-height 1s ease-in-out;
}
}
.avatar {
border-color: darken($gray-normal, 10%);
img {
width: 32px;
height: 32px;
}
}
}
......@@ -641,6 +641,7 @@ $input-lg-width: 320px;
*/
$document-index-color: #888;
$help-shortcut-header-color: #333;
$accepting-mr-label-color: #69d100;
/*
* Issues
......
<script>
import { __, s__, sprintf } from '~/locale';
import { GlLink, GlProgressBar, GlButton } from '@gitlab/ui';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import Icon from '~/vue_shared/components/icon.vue';
import HelpContentPopover from './help_content_popover.vue';
import TourPartsList from './tour_parts_list.vue';
export default {
name: 'OnboardingHelper',
components: {
userAvatarImage,
Icon,
GlLink,
GlProgressBar,
GlButton,
HelpContentPopover,
TourPartsList,
},
props: {
tourTitles: {
type: Array,
required: true,
},
activeTour: {
type: Number,
required: false,
default: null,
},
totalStepsForTour: {
type: Number,
required: false,
default: 0,
},
helpContent: {
type: Object,
required: false,
default: null,
},
percentageCompleted: {
type: Number,
required: false,
default: 0,
},
completedSteps: {
type: Number,
required: false,
default: 0,
},
initialShow: {
type: Boolean,
required: false,
default: false,
},
dismissPopover: {
type: Boolean,
required: false,
default: false,
},
goldenTanukiSvgPath: {
type: String,
required: true,
},
},
data() {
return {
expanded: false,
showPopover: false,
popoverDismissed: false,
helpContentTrigger: null,
};
},
computed: {
totalTours() {
return this.tourTitles.length;
},
tourInfo() {
return sprintf(s__('UserOnboardingTour|%{activeTour}/%{totalTours}'), {
activeTour: this.activeTour,
totalTours: this.totalTours,
});
},
hasTourTitles() {
return this.totalTours > 0;
},
toggleButtonLabel() {
return this.expanded ? __('Close') : __('More');
},
toggleButtonIcon() {
return this.expanded ? 'close' : 'ellipsis_h';
},
showLink() {
return this.activeTour && Boolean(this.helpContent);
},
},
watch: {
initialShow(newVal) {
if (newVal) {
this.showPopover = newVal;
}
},
dismissPopover(newVal) {
this.popoverDismissed = newVal;
if (newVal) {
this.showPopover = false;
}
},
},
mounted() {
this.helpContentTrigger = this.$refs.onboardingHelper;
},
methods: {
transitionEndCallback() {
if (!this.popoverDismissed && !this.expanded) {
this.showPopover = true;
}
},
toggleMenu() {
this.expanded = !this.expanded;
if (!this.popoverDismissed && this.expanded) {
this.showPopover = false;
}
},
skipStep() {
this.$emit('skipStep');
},
restartStep() {
this.$emit('restartStep');
},
showExitTourContent() {
this.$emit('showExitTourContent', true);
},
callButtonAction(button) {
this.$emit('clickPopoverButton', button);
},
},
};
</script>
<template>
<div
id="js-onboarding-helper"
ref="onboardingHelper"
class="onboarding-helper-container d-none d-lg-block position-fixed"
:class="{ expanded: expanded }"
@click="toggleMenu"
@transitionend="transitionEndCallback"
>
<help-content-popover
v-if="helpContent && helpContentTrigger"
:help-content="helpContent"
:target="helpContentTrigger"
:show="showPopover"
:disabled="popoverDismissed"
@clickActionButton="callButtonAction"
/>
<div class="d-flex align-items-center cursor-pointer">
<div class="avatar s48 mr-1 d-flex">
<img :src="goldenTanukiSvgPath" :alt="s__('Golden Tanuki')" class="m-auto" />
</div>
<div class="d-flex flex-grow justify-content-between">
<div class="qa-headline">
<strong class="title">{{ s__('UserOnboardingTour|Learn GitLab') }}</strong>
<strong v-if="activeTour">{{ tourInfo }}</strong>
<gl-progress-bar class="mt-1" :value="percentageCompleted" variant="info" />
</div>
<gl-button
class="qa-toggle-btn btn btn-transparent mr-1"
type="button"
:aria-label="toggleButtonLabel"
>
<icon :size="14" :name="toggleButtonIcon" />
</gl-button>
</div>
</div>
<div class="collapsible overflow-hidden">
<div v-if="hasTourTitles" class="qa-tour-parts-list">
<tour-parts-list
:tour-titles="tourTitles"
:active-tour="activeTour"
:total-steps-for-tour="totalStepsForTour"
:completed-steps="completedSteps"
/>
</div>
<hr class="my-2" />
<ul class="list-unstyled mx-2 mb-2">
<li v-if="showLink">
<gl-link class="qa-skip-step-link d-inline-flex" @click="skipStep">
<icon name="collapse-right" class="mr-1" />
<span>{{ s__('UserOnboardingTour|Skip this step') }}</span>
</gl-link>
</li>
<li v-if="showLink">
<gl-link class="qa-restart-step-link d-inline-flex" @click="restartStep">
<icon name="repeat" class="mr-1" />
<span>{{ s__('UserOnboardingTour|Restart this step') }}</span>
</gl-link>
</li>
<li>
<gl-link class="qa-exit-tour-link d-inline-flex" @click="showExitTourContent">
<icon name="leave" class="mr-1" />
<span>{{ s__("UserOnboardingTour|Exit 'Learn GitLab'") }}</span>
</gl-link>
</li>
</ul>
</div>
</div>
</template>
<script>
import { s__, sprintf } from '~/locale';
export default {
name: 'TourPartsList',
props: {
tourTitles: {
type: Array,
required: true,
},
activeTour: {
type: Number,
required: false,
default: null,
},
totalStepsForTour: {
type: Number,
required: false,
default: 0,
},
completedSteps: {
type: Number,
required: false,
default: 0,
},
},
computed: {
stepsCompletedInfo() {
return sprintf(s__('UserOnboardingTour|%{completed}/%{total} steps completed'), {
completed: this.completedSteps,
total: this.totalStepsForTour,
});
},
},
methods: {
isActiveTour(tourNo) {
return tourNo === this.activeTour;
},
},
};
</script>
<template>
<ul class="list-unstyled">
<li
v-for="tour in tourTitles"
:key="tour.id"
class="tour-item my-2 px-2"
:class="{ active: isActiveTour(tour.id), 'py-2': isActiveTour(tour.id) }"
>
<span class="tour-title" :class="{ 'text-info': isActiveTour(tour.id) }"
><strong>{{ tour.id }}</strong> {{ tour.title }}</span
>
<div v-if="isActiveTour(tour.id)" class="text-secondary">{{ stepsCompletedInfo }}</div>
</li>
</ul>
</template>
<style scoped>
.tour-item.active {
background: #f6fafe;
}
.tour-item.active .tour-title {
font-weight: bold;
}
</style>
import component from 'ee/onboarding/onboarding_helper/components/onboarding_helper.vue';
import TourPartsList from 'ee/onboarding/onboarding_helper/components/tour_parts_list.vue';
import Icon from '~/vue_shared/components/icon.vue';
import { shallowMount } from '@vue/test-utils';
import { GlProgressBar } from '@gitlab/ui';
describe('User onboarding tour parts list', () => {
let wrapper;
const defaultProps = {
tourTitles: [
{ id: 1, title: 'First tour' },
{ id: 2, title: 'Second tour' },
{ id: 3, title: 'Yet another tour' },
],
activeTour: 1,
totalStepsForTour: 10,
helpContent: {
text: 'help content popover text',
buttons: [{ text: 'OK', btnClass: 'btn-primary' }],
},
percentageCompleted: 50,
completedSteps: 3,
initialShow: false,
dismissPopover: false,
goldenTanukiSvgPath: 'illustrations/golden_tanuki.svg',
};
function createComponent(propsData) {
wrapper = shallowMount(component, { propsData });
}
beforeEach(() => {
createComponent(defaultProps);
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('totalTours', () => {
it('returns the total number of tours', () => {
expect(wrapper.vm.totalTours).toBe(defaultProps.tourTitles.length);
});
});
describe('tourInfo', () => {
it('returns "1/3"', () => {
expect(wrapper.vm.tourInfo).toEqual('1/3');
});
});
describe('toggleButtonLabel', () => {
it('returns "More" if the helper is collapsed', () => {
expect(wrapper.vm.toggleButtonLabel).toEqual('More');
});
it('returns "Close" if the helper is expanded', () => {
wrapper.vm.expanded = true;
expect(wrapper.vm.toggleButtonLabel).toEqual('Close');
});
});
describe('toggleButtonIcon', () => {
it('returns "ellipsis_h" if the helper is collapsed', () => {
expect(wrapper.vm.toggleButtonIcon).toEqual('ellipsis_h');
});
it('returns "close" if the helper is expanded', () => {
wrapper.vm.expanded = true;
expect(wrapper.vm.toggleButtonIcon).toEqual('close');
});
});
describe('showLink', () => {
it('returns true per default', () => {
expect(wrapper.vm.showLink).toBe(true);
});
it('returns false if the activeTour is null', () => {
const props = {
...defaultProps,
activeTour: null,
};
createComponent(props);
expect(wrapper.vm.showLink).toBeFalsy();
});
it('returns false if the helpContent is null', () => {
const props = {
...defaultProps,
helpContent: null,
};
createComponent(props);
expect(wrapper.vm.showLink).toBeFalsy();
});
it('returns false if the helpContent is undefined', () => {
const props = {
...defaultProps,
helpContent: undefined,
};
createComponent(props);
expect(wrapper.vm.showLink).toBeFalsy();
});
});
});
describe('mounted', () => {
it('sets the helpContentTrigger', () => {
expect(wrapper.vm.helpContentTrigger).not.toBe(null);
});
});
describe('watch', () => {
describe('watch initialShow', () => {
it('sets showPopover to true if initialShow is true', () => {
wrapper.setProps({ initialShow: true });
expect(wrapper.vm.showPopover).toBe(true);
});
});
describe('watch dismissPopover', () => {
it('sets popoverDismissed to true and showPopover to false if dismissPopover is true', done => {
wrapper.setProps({ dismissPopover: true });
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.showPopover).toBe(false);
expect(wrapper.vm.popoverDismissed).toBe(true);
done();
});
});
it('sets popoverDismissed to false dismissPopover is false', done => {
wrapper.setProps({ dismissPopover: false });
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.popoverDismissed).toBe(false);
done();
});
});
});
});
describe('methods', () => {
describe('transitionEndCallback', () => {
it('sets showPopover to true if popoverDismissed and expanded are false', () => {
wrapper.vm.popoverDismissed = false;
wrapper.vm.expanded = false;
wrapper.vm.showPopover = false;
wrapper.vm.transitionEndCallback();
expect(wrapper.vm.showPopover).toBe(true);
});
});
describe('toggleMenu', () => {
it('expands and collapses the menu correctly', () => {
wrapper.vm.expanded = false;
wrapper.vm.toggleMenu();
expect(wrapper.vm.expanded).toBe(true);
wrapper.vm.expanded = true;
wrapper.vm.toggleMenu();
expect(wrapper.vm.expanded).toBe(false);
});
it('hides the popover if currently expanded and popoverDismissed is false', () => {
wrapper.vm.expanded = true;
wrapper.vm.popoverDismissed = false;
wrapper.vm.toggleMenu();
expect(wrapper.vm.showPopover).toBe(false);
});
});
describe('skipStep', () => {
it('emits the "skipStep" event when the "Skip this step" link is clicked', () => {
wrapper.find('.qa-skip-step-link').vm.$emit('click');
expect(wrapper.emitted('skipStep')).toBeTruthy();
});
});
describe('restartStep', () => {
it('emits the "restartStep" event when the "Restart this step" link is clicked', () => {
wrapper.find('.qa-restart-step-link').vm.$emit('click');
expect(wrapper.emitted('restartStep')).toBeTruthy();
});
});
describe('showExitTourContent', () => {
it('emits the "showExitTourContent" event when the "Exit Learn GitLab" link is clicked', () => {
wrapper.find('.qa-exit-tour-link').vm.$emit('click');
expect(wrapper.emitted('showExitTourContent')).toBeTruthy();
});
});
describe('callButtonAction', () => {
it('emits the "clickPopoverButton" event when a popover button is clicked', () => {
const button = defaultProps.helpContent.buttons[0];
wrapper.vm.callButtonAction(button);
expect(wrapper.emittedByOrder()).toEqual([{ name: 'clickPopoverButton', args: [button] }]);
});
});
});
describe('template', () => {
it('it adds the "expanded" class to the container if expanded is true', done => {
wrapper.vm.expanded = true;
wrapper.vm.$nextTick(() => {
expect(wrapper.classes('expanded')).toEqual(true);
done();
});
});
it('renders the tanuki illustration', () => {
const img = wrapper.find('.avatar img');
expect(img.exists()).toBe(true);
expect(img.attributes('src')).toEqual(defaultProps.goldenTanukiSvgPath);
});
it('renders the headline', () => {
const headline = wrapper.find('.qa-headline');
const title = headline.find('.title');
expect(headline.exists()).toBe(true);
expect(title.text()).toBe('Learn GitLab');
expect(headline.text()).toContain('1/3');
});
it('renders the progress bar with the correct value', () => {
const progressBar = wrapper.find(GlProgressBar);
expect(progressBar.exists()).toBe(true);
expect(progressBar.attributes('value')).toEqual(`${defaultProps.percentageCompleted}`);
});
it('renders the toggle button', () => {
expect(wrapper.find('.qa-toggle-btn').exists()).toBe(true);
});
it('renders the proper toggle button icons', () => {
const btn = wrapper.find('.qa-toggle-btn');
const icon = btn.find(Icon);
expect(icon.props('name')).toEqual('ellipsis_h');
wrapper.vm.expanded = true;
expect(icon.props('name')).toEqual('close');
});
it('renders the tour parts list if there are tour titles', () => {
const { tourTitles, activeTour, totalStepsForTour, completedSteps } = defaultProps;
expect(wrapper.find(TourPartsList).exists()).toBe(true);
expect(wrapper.find(TourPartsList).props()).toEqual(
jasmine.objectContaining({
tourTitles,
activeTour,
totalStepsForTour,
completedSteps,
}),
);
});
it("does not render the tour parts list if there aren't tour titles", () => {
const props = {
...defaultProps,
tourTitles: [],
};
createComponent(props);
expect(wrapper.find(TourPartsList).exists()).toBe(false);
});
it('renders "Skip this step", "Restart this step" and "Exit Learn GitLab" links', () => {
expect(wrapper.find('.qa-skip-step-link').exists()).toBe(true);
expect(wrapper.find('.qa-restart-step-link').exists()).toBe(true);
});
it('does not render the "Skip this step" and "Restart this step" links if showLink is false', () => {
const props = {
...defaultProps,
activeTour: null,
};
createComponent(props);
expect(wrapper.find('.qa-skip-step-link').exists()).toBe(false);
expect(wrapper.find('.qa-skip-step-link').exists()).toBe(false);
});
});
});
import component from 'ee/onboarding/onboarding_helper/components/tour_parts_list.vue';
import { shallowMount } from '@vue/test-utils';
describe('User onboarding tour parts list', () => {
let wrapper;
const tourTitles = [
{ id: 1, title: 'First tour' },
{ id: 2, title: 'Second tour' },
{ id: 3, title: 'Yet another tour' },
];
const defaultProps = {
tourTitles,
activeTour: 1,
totalStepsForTour: 10,
completedSteps: 3,
};
let tourItems;
function createComponent(propsData) {
wrapper = shallowMount(component, { propsData });
}
beforeEach(() => {
createComponent(defaultProps);
tourItems = wrapper.findAll('.tour-item');
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('stepsCompletedInfo', () => {
it('returns "3/10 steps completed"', () => {
expect(wrapper.vm.stepsCompletedInfo).toEqual('3/10 steps completed');
});
});
});
describe('methods', () => {
describe('isActiveTour', () => {
it('returns true when the given tour number is active', () => {
expect(wrapper.vm.isActiveTour(1)).toBeTruthy();
});
it('returns false when the given tour number is not active', () => {
expect(wrapper.vm.isActiveTour(2)).toBeFalsy();
});
});
});
describe('template', () => {
it('renders a list item for each tour title', () => {
expect(wrapper.findAll('.tour-item').length).toEqual(tourTitles.length);
});
it('adds the "active" class to the first tour item', () => {
expect(tourItems.at(0).classes('active')).toEqual(true);
});
it('does not add the "active" class to the second tour item', () => {
expect(tourItems.at(1).classes('active')).toEqual(false);
});
it('adds the "text-info" class to the tour title of the first item', () => {
const tourTitle = tourItems.at(0).find('.tour-title');
expect(tourTitle.classes('text-info')).toEqual(true);
});
it('does not add the "text-info" class to the tour title of the second item', () => {
const tourTitle = tourItems.at(1).find('.tour-title');
expect(tourTitle.classes('text-info')).toEqual(false);
});
it('renders "3/10 steps completed" below the first tour item', () => {
const completedInfo = tourItems.at(0).find('.text-secondary');
expect(completedInfo.exists()).toBe(true);
expect(completedInfo.text()).toEqual('3/10 steps completed');
});
it('does not render "3/10 steps completed" below the second tour item', () => {
const completedInfo = tourItems.at(1).find('.text-secondary');
expect(completedInfo.exists()).toBe(false);
});
});
});
......@@ -6362,6 +6362,9 @@ msgstr ""
msgid "Go to your fork"
msgstr ""
msgid "Golden Tanuki"
msgstr ""
msgid "Google Code import"
msgstr ""
......@@ -14466,6 +14469,24 @@ msgstr ""
msgid "User was successfully updated."
msgstr ""
msgid "UserOnboardingTour|%{activeTour}/%{totalTours}"
msgstr ""
msgid "UserOnboardingTour|%{completed}/%{total} steps completed"
msgstr ""
msgid "UserOnboardingTour|Exit 'Learn GitLab'"
msgstr ""
msgid "UserOnboardingTour|Learn GitLab"
msgstr ""
msgid "UserOnboardingTour|Restart this step"
msgstr ""
msgid "UserOnboardingTour|Skip this step"
msgstr ""
msgid "UserProfile|Activity"
msgstr ""
......
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