Commit 04109f4b authored by Doug Stull's avatar Doug Stull

Remove frontend code for onboarding tour

- no longer needed.
parent f3d635d3
...@@ -100,26 +100,6 @@ ...@@ -100,26 +100,6 @@
} }
} }
.onboarding-popover {
box-shadow: 0 2px 4px $dropdown-shadow-color;
max-width: 280px;
.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;
}
}
/** /**
* user_popover component * user_popover component
*/ */
...@@ -132,13 +112,6 @@ ...@@ -132,13 +112,6 @@
} }
} }
.onboarding-welcome-page {
.popover {
min-width: auto;
max-width: 40%;
}
}
.suggest-gitlab-ci-yml { .suggest-gitlab-ci-yml {
margin-top: -1em; margin-top: -1em;
......
...@@ -553,41 +553,6 @@ img.emoji { ...@@ -553,41 +553,6 @@ img.emoji {
} }
} }
.onboarding-helper-container {
bottom: 40px;
right: 40px;
font-size: $gl-font-size-small;
background: $gray-50;
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;
}
}
}
.gl-font-sm { font-size: $gl-font-size-small; } .gl-font-sm { font-size: $gl-font-size-small; }
.gl-font-lg { font-size: $gl-font-size-large; } .gl-font-lg { font-size: $gl-font-size-large; }
.gl-font-base { font-size: $gl-font-size-14; } .gl-font-base { font-size: $gl-font-size-14; }
......
...@@ -711,7 +711,6 @@ $input-lg-width: 320px; ...@@ -711,7 +711,6 @@ $input-lg-width: 320px;
*/ */
$document-index-color: #888; $document-index-color: #888;
$help-shortcut-header-color: #333; $help-shortcut-header-color: #333;
$accepting-mr-label-color: #69d100;
/* /*
* Issues * Issues
......
...@@ -13,6 +13,4 @@ ...@@ -13,6 +13,4 @@
= render 'layouts/page', sidebar: sidebar, nav: nav = render 'layouts/page', sidebar: sidebar, nav: nav
= footer_message = footer_message
= render_if_exists "shared/onboarding_guide"
= yield :scripts_body = yield :scripts_body
...@@ -35,7 +35,6 @@ ...@@ -35,7 +35,6 @@
= link_to _("Help"), help_path = link_to _("Help"), help_path
%li.d-md-none %li.d-md-none
= link_to _("Support"), support_url = link_to _("Support"), support_url
= render_if_exists "shared/learn_gitlab_menu_item"
%li.d-md-none %li.d-md-none
= link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
......
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
%button.js-shortcuts-modal-trigger{ type: "button" } %button.js-shortcuts-modal-trigger{ type: "button" }
= _("Keyboard shortcuts") = _("Keyboard shortcuts")
%span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe %span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe
= render_if_exists "shared/learn_gitlab_menu_item"
%li.divider %li.divider
%li %li
= link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
......
import $ from 'jquery'; import $ from 'jquery';
import initEETrialBanner from 'ee/ee_trial_banner'; import initEETrialBanner from 'ee/ee_trial_banner';
import trackNavbarEvents from 'ee/event_tracking/navbar'; import trackNavbarEvents from 'ee/event_tracking/navbar';
import initOnboarding from 'ee/onboarding/onboarding_helper';
$(() => { $(() => {
/** /**
...@@ -13,6 +12,4 @@ $(() => { ...@@ -13,6 +12,4 @@ $(() => {
initEETrialBanner(); initEETrialBanner();
trackNavbarEvents(); trackNavbarEvents();
initOnboarding();
}); });
import { s__, sprintf } from '~/locale';
import { glEmojiTag } from '~/emoji';
export const ONBOARDING_DISMISSED_COOKIE_NAME = 'onboarding_dismissed';
export const STORAGE_KEY = 'onboarding_state';
export const AVAILABLE_TOURS = {
GUIDED_GITLAB_TOUR: 1,
CREATE_PROJECT_TOUR: 2,
INVITE_COLLEAGUES_TOUR: 3,
};
export const TOUR_TITLES = [
{ id: AVAILABLE_TOURS.GUIDED_GITLAB_TOUR, title: s__('UserOnboardingTour|Guided GitLab Tour') },
{ id: AVAILABLE_TOURS.CREATE_PROJECT_TOUR, title: s__('UserOnboardingTour|Create a project') },
{
id: AVAILABLE_TOURS.INVITE_COLLEAGUES_TOUR,
title: s__('UserOnboardingTour|Invite colleagues'),
},
];
export const ONBOARDING_PROPS_DEFAULTS = {
tourKey: AVAILABLE_TOURS.GUIDED_GITLAB_TOUR,
lastStepIndex: -1,
createdProjectPath: '',
};
export const ACCEPTING_MR_LABEL_TEXT = 'Accepting merge requests';
export const LABEL_SEARCH_QUERY = `scope=all&state=opened&label_name[]=${encodeURIComponent(
ACCEPTING_MR_LABEL_TEXT,
)}`;
export const FEEDBACK_CONTENT = {
text: sprintf(
s__(
"UserOnboardingTour|Great job! %{clapHands} We hope the tour was helpful and that you learned how to use GitLab.%{lineBreak}%{lineBreak}We'd love to get your feedback on this tour.%{lineBreak}%{lineBreak}%{emphasisStart}How helpful would you say this guided tour was?%{emphasisEnd}%{lineBreak}%{lineBreak}",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '<br/>',
clapHands: glEmojiTag('clap'),
},
false,
),
feedbackButtons: true,
feedbackSize: 5,
};
export const EXIT_TOUR_CONTENT = {
text: sprintf(
s__('UserOnboardingTour|Thanks for the feedback! %{thumbsUp}'),
{
thumbsUp: glEmojiTag('thumbsup'),
},
false,
),
buttonText: s__("UserOnboardingTour|Close 'Learn GitLab'"),
exitTour: true,
};
export const DNT_EXIT_TOUR_CONTENT = {
text: sprintf(
s__(
'UserOnboardingTour|Thanks for taking the guided tour. Remember, if you want to go through it again, you can start %{emphasisStart}Learn GitLab%{emphasisEnd} in the help menu on the top right.',
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
buttonText: s__('UserOnboardingTour|Got it'),
exitTour: true,
};
import onboardingUtils from './utils';
import { AVAILABLE_TOURS } from './constants';
export const getProjectPath = () => {
let projectPath;
const activeTab = document.querySelector('.js-toggle-container.active');
const projectPathInput = activeTab.querySelector('#project_path');
const select = activeTab.querySelector('select.js-select-namespace');
if (!projectPathInput) {
return '';
}
if (select) {
const selectedOption = select.options[select.selectedIndex];
const { showPath } = selectedOption.dataset;
projectPath = `${showPath}/${projectPathInput.value}`;
} else {
projectPath = projectPathInput.value;
}
return projectPath;
};
/**
* Binds a submit event handler to the form on the "New project" page (for user onboarding only).
* It intercepts form submit and sets the project path of project to be created on the localStorage.
* The project path is used later in the onboarding process.
*
* @param {*} form The form we're going to add the submit event handler to
*/
export const bindOnboardingEvents = form => {
if (!form) {
return;
}
const onboardingState = onboardingUtils.getOnboardingLocalStorageState();
if (
!onboardingUtils.isOnboardingDismissed() &&
onboardingState &&
onboardingState.tourKey === AVAILABLE_TOURS.CREATE_PROJECT_TOUR
) {
form.addEventListener('submit', event => {
event.preventDefault();
event.stopPropagation();
const createdProjectPath = getProjectPath();
onboardingUtils.updateLocalStorage({ createdProjectPath });
form.submit();
});
}
};
import Vue from 'vue';
import ActionPopover from './components/action_popover.vue';
// retry for 10 times (5 seconds in total)
const maxTries = 10;
const timeout = 500;
const mountComponent = (intervalId, el, { target, content, placement, showPopover }) => {
clearInterval(intervalId);
return new Vue({
el,
render(h) {
return h(ActionPopover, {
props: {
target,
content,
placement,
showDefault: showPopover,
},
});
},
});
};
const renderPopover = (popoverSelector, content, placement, showPopover) => {
const popoverContainer = document.getElementById('js-onboarding-action-popover');
let retry = 0;
if (!popoverContainer) {
return false;
}
// continuously check if target element already exists (might be delayed to to dynamic component creation)
const intervalId = setInterval(() => {
if (retry >= maxTries) {
clearInterval(intervalId);
}
retry += 1;
const target = document.querySelector(popoverSelector);
if (!target) {
return false;
}
return mountComponent(intervalId, popoverContainer, {
target,
content,
placement,
showPopover,
});
}, timeout);
return intervalId;
};
const actionPopoverUtils = {
renderPopover,
};
export default actionPopoverUtils;
<script>
import { GlPopover } from '@gitlab/ui';
import eventHub from '../event_hub';
export default {
name: 'ActionPopover',
components: {
GlPopover,
},
props: {
target: {
type: HTMLElement,
required: true,
},
content: {
type: String,
required: false,
default: '',
},
placement: {
type: String,
required: false,
default: 'top',
},
showDefault: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
showPopover: this.showDefault,
};
},
mounted() {
eventHub.$on('onboardingHelper.showActionPopover', () => this.toggleShowPopover(true));
eventHub.$on('onboardingHelper.hideActionPopover', () => this.toggleShowPopover(false));
eventHub.$on('onboardingHelper.destroyActionPopover', () =>
this.$root.$off('bv::popover::show'),
);
},
beforeDestroy() {
eventHub.$off('onboardingHelper.showActionPopover');
eventHub.$off('onboardingHelper.hideActionPopover');
eventHub.$off('onboardingHelper.destroyActionPopover');
},
methods: {
toggleShowPopover(show) {
this.showPopover = show;
},
},
};
</script>
<template>
<gl-popover
v-bind="$attrs"
:target="target"
boundary="viewport"
:placement="placement"
:show="showPopover"
:css-classes="['blue', 'onboarding-popover']"
>
<div v-html="content"></div>
</gl-popover>
</template>
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { redirectTo } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import OnboardingHelper from './onboarding_helper.vue';
import actionPopoverUtils from '../action_popover_utils';
import eventHub from '../event_hub';
const TRACKING_CATEGORY = 'onboarding';
export default {
components: {
OnboardingHelper,
},
props: {
tourTitles: {
type: Array,
required: true,
},
feedbackContent: {
type: Object,
required: true,
},
dntExitTourContent: {
type: Object,
required: true,
},
exitTourContent: {
type: Object,
required: true,
},
goldenTanukiSvgPath: {
type: String,
required: true,
},
},
data() {
return {
showStepContent: false,
initialShowPopover: false,
dismissPopover: false,
};
},
computed: {
...mapState([
'projectName',
'tourKey',
'tourData',
'lastStepIndex',
'helpContentIndex',
'tourFeedback',
'exitTour',
'dntExitTour',
'dismissed',
]),
...mapGetters([
'stepIndex',
'stepContent',
'helpContent',
'totalTourPartSteps',
'percentageCompleted',
'actionPopover',
]),
helpContentData() {
if (!this.showStepContent) return null;
if (this.exitTour) return this.exitTourContent;
if (this.tourFeedback) return this.feedbackContent;
if (this.dntExitTour) return this.dntExitTourContent;
return this.helpContent;
},
completedSteps() {
return Math.max(this.lastStepIndex, 0);
},
},
mounted() {
this.init();
},
methods: {
...mapActions([
'setTourKey',
'setLastStepIndex',
'setHelpContentIndex',
'switchTourPart',
'setExitTour',
'setTourFeedback',
'setDntExitTour',
'setDismissed',
]),
init() {
// ensure we show help content on consecutive pages only
if (this.tourKey) {
const nextStepIndex = this.lastStepIndex + 1;
// show help content when the current was the last visited page (e.g., user navigates away and comes back to current page)
if (this.lastStepIndex === this.stepIndex) {
this.showStepContent = true;
this.initActionPopover();
// show help content when this is the upcoming page in the content list (otherwise don't show the help content)
// and update the lastStepIndex
} else if (nextStepIndex === this.stepIndex) {
this.setLastStepIndex(nextStepIndex);
this.showStepContent = true;
this.initActionPopover();
}
}
},
initActionPopover() {
if (this.actionPopover) {
const { selector, text, placement } = this.actionPopover;
// immediately show the action popover if there's not helpContent for this step
const showPopover = !this.helpContent && selector !== undefined;
actionPopoverUtils.renderPopover(selector, text, placement, showPopover);
}
},
showActionPopover() {
eventHub.$emit('onboardingHelper.showActionPopover');
},
hideActionPopover() {
eventHub.$emit('onboardingHelper.hideActionPopover');
},
handleRestartStep() {
this.showExitTourContent(false);
this.handleFeedbackTourContent(false);
Tracking.event(TRACKING_CATEGORY, 'click_link', {
label: this.getTrackingLabel(),
property: 'restart_this_step',
});
eventHub.$emit('onboardingHelper.hideActionPopover');
},
handleSkipStep() {
if (this.actionPopover) {
const { selector } = this.actionPopover;
const popoverEl = selector ? document.querySelector(selector) : null;
if (popoverEl) {
Tracking.event(TRACKING_CATEGORY, 'click_link', {
label: this.getTrackingLabel(),
property: 'skip_this_step',
});
popoverEl.click();
}
}
},
handleStepContentButton(button) {
const { showExitTourContent, redirectPath, nextPart, dismissPopover } = button;
const helpContentItems = this.stepContent
? this.stepContent.getHelpContent({ projectName: this.projectName })
: null;
const showNextContentItem =
helpContentItems &&
helpContentItems.length > 1 &&
this.helpContentIndex < helpContentItems.length - 1;
// display exit tour content
if (showExitTourContent) {
this.handleShowExitTourContent(true);
return;
}
// dismiss popover if necessary
if (dismissPopover === undefined || dismissPopover === true) {
this.dismissPopover = true;
}
// redirect to redirectPath
if (redirectPath) {
redirectTo(redirectPath);
return;
}
// switch to the next tour part
if (nextPart !== undefined) {
this.switchTourPart(nextPart);
this.initActionPopover();
return;
}
// switch to next content item
if (showNextContentItem) {
this.setHelpContentIndex(this.helpContentIndex + 1);
return;
}
Tracking.event(TRACKING_CATEGORY, 'click_button', {
label: this.getTrackingLabel(),
property: 'got_it',
});
this.showActionPopover();
},
handleFeedbackButton(button) {
const { feedbackResult } = button;
// track feedback
if (feedbackResult) this.trackFeedback(feedbackResult);
// display exit tour content
this.handleShowExitTourContent(true);
},
trackFeedback(feedbackResult) {
Tracking.event(TRACKING_CATEGORY, 'click_link', {
label: 'feedback',
property: 'feedback_result',
value: feedbackResult,
});
},
handleShowExitTourContent(showExitTour) {
Tracking.event(TRACKING_CATEGORY, 'click_link', {
label: this.getTrackingLabel(),
property: 'exit_learn_gitlab',
});
this.showExitTourContent(showExitTour);
},
handleFeedbackTourContent(showTourFeedback) {
this.configureEndingTourPopup();
this.setTourFeedback(showTourFeedback);
},
handleDntExitTourContent(showExitTour) {
this.configureEndingTourPopup();
this.setDntExitTour(showExitTour);
},
showExitTourContent(showExitTour) {
this.configureEndingTourPopup();
this.setExitTour(showExitTour);
},
configureEndingTourPopup() {
this.dismissPopover = false;
this.showStepContent = true;
},
handleExitTourButton() {
this.hideActionPopover();
this.setDismissed(true);
// remove popover event handlers
eventHub.$emit('onboardingHelper.destroyActionPopover');
},
afterAppearHook() {
this.initialShowPopover = true;
},
getTrackingLabel() {
const step = this.stepIndex + 1;
return `part_${this.tourKey}_step_${step}`;
},
},
};
</script>
<template>
<transition appear name="slide-in-fwd-bottom" @after-appear="afterAppearHook">
<onboarding-helper
v-if="!dismissed"
:tour-titles="tourTitles"
:active-tour="tourKey"
:completed-steps="completedSteps"
:help-content="helpContentData"
:percentage-completed="percentageCompleted"
:total-steps-for-tour="totalTourPartSteps"
:initial-show="initialShowPopover"
:dismiss-popover="dismissPopover"
:golden-tanuki-svg-path="goldenTanukiSvgPath"
@clickStepContentButton="handleStepContentButton"
@clickExitTourButton="handleExitTourButton"
@clickFeedbackButton="handleFeedbackButton"
@restartStep="handleRestartStep"
@skipStep="handleSkipStep"
@showFeedbackContent="handleFeedbackTourContent"
@showDntExitContent="handleDntExitTourContent"
@showExitTourContent="handleShowExitTourContent"
/>
</transition>
</template>
<script>
import { GlPopover, GlDeprecatedButton, GlButtonGroup } from '@gitlab/ui';
export default {
name: 'HelpContentPopover',
components: {
GlPopover,
GlDeprecatedButton,
GlButtonGroup,
},
props: {
target: {
type: HTMLElement,
required: true,
},
helpContent: {
type: Object,
required: false,
default: null,
},
placement: {
type: String,
required: false,
default: 'top',
},
show: {
type: Boolean,
required: false,
default: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
callStepContentButton(button) {
this.$emit('clickStepContentButton', button);
},
callExitTour() {
this.$emit('clickExitTourButton');
},
submitFeedback(button) {
this.$emit('clickFeedbackButton', button);
},
},
};
</script>
<template>
<gl-popover
v-bind="$attrs"
:target="target"
:placement="placement"
:show="show"
:disabled="disabled"
:css-classes="['onboarding-popover']"
>
<div>
<p v-html="helpContent.text"></p>
<template v-if="helpContent.buttons">
<template v-for="(button, index) in helpContent.buttons">
<gl-deprecated-button
v-if="!button.readOnly"
:key="index"
:class="button.btnClass"
class="btn btn-sm mr-2"
@click="callStepContentButton(button)"
>
{{ button.text }}
</gl-deprecated-button>
<span v-else :key="index" :class="button.btnClass" class="btn btn-sm mr-2">
{{ button.text }}
</span>
</template>
</template>
<template v-if="helpContent.exitTour">
<gl-deprecated-button class="btn btn-sm btn-primary mr-2" @click="callExitTour">
{{ helpContent.buttonText }}
</gl-deprecated-button>
</template>
<template v-if="helpContent.feedbackButtons">
<gl-button-group>
<gl-deprecated-button
v-for="feedbackValue in helpContent.feedbackSize"
:key="feedbackValue"
@click="
submitFeedback({
feedbackResult: feedbackValue,
})
"
>
{{ feedbackValue }}
</gl-deprecated-button>
</gl-button-group>
<div class="pt-1">
<small>{{ __('Not helpful') }}</small>
<small class="ml-4">{{ __('Very helpful') }}</small>
</div>
</template>
</div>
</gl-popover>
</template>
<script>
import { GlLink, GlProgressBar, GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import HelpContentPopover from './help_content_popover.vue';
import TourPartsList from './tour_parts_list.vue';
import Tracking from '~/tracking';
export default {
name: 'OnboardingHelper',
components: {
Icon,
GlLink,
GlProgressBar,
GlDeprecatedButton,
GlLoadingIcon,
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,
showLoadingIcon: false,
};
},
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.showLoadingIcon = true;
this.$emit('skipStep');
},
restartStep() {
this.$emit('restartStep');
},
beginExitTourProcess() {
if (Tracking.enabled()) {
this.$emit('showFeedbackContent', true);
} else {
this.$emit('showDntExitContent', true);
}
},
callStepContentButton(button) {
this.$emit('clickStepContentButton', button);
},
callExitTour() {
this.$emit('clickExitTourButton');
},
submitFeedback(button) {
this.$emit('clickFeedbackButton', 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"
@clickStepContentButton="callStepContentButton"
@clickExitTourButton="callExitTour"
@clickFeedbackButton="submitFeedback"
/>
<div class="d-flex align-items-center cursor-pointer">
<div class="avatar s48 mr-1 d-flex">
<img
v-if="!showLoadingIcon"
:src="goldenTanukiSvgPath"
:alt="s__('Golden Tanuki')"
class="m-auto"
/>
<gl-loading-icon v-else :inline="true" 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-deprecated-button
class="qa-toggle-btn btn btn-transparent mr-1"
type="button"
:aria-label="toggleButtonLabel"
>
<icon :size="14" :name="toggleButtonIcon" />
</gl-deprecated-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="beginExitTourProcess">
<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 createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
import Vue from 'vue';
import { mapActions } from 'vuex';
import OnboardingApp from './components/app.vue';
import createStore from './store';
import onboardingUtils from '../utils';
import {
TOUR_TITLES,
FEEDBACK_CONTENT,
EXIT_TOUR_CONTENT,
DNT_EXIT_TOUR_CONTENT,
} from '../constants';
import TOUR_PARTS from '../tour_parts';
export default function() {
const el = document.getElementById('js-onboarding-helper');
if (!el) {
return false;
}
const tourData = onboardingUtils.getOnboardingLocalStorageState();
if (!tourData || onboardingUtils.isOnboardingDismissed()) {
return false;
}
const { projectFullPath, projectName, goldenTanukiSvgPath } = el.dataset;
const url = window.location.href;
const { tourKey, lastStepIndex, createdProjectPath } = tourData;
const store = createStore();
return new Vue({
el,
store,
components: {
OnboardingApp,
},
created() {
if (tourKey) {
this.setInitialData({
url,
projectFullPath,
projectName,
tourData: TOUR_PARTS,
tourKey,
lastStepIndex,
createdProjectPath,
});
}
},
methods: {
...mapActions(['setInitialData']),
},
render(h) {
return h(OnboardingApp, {
props: {
tourTitles: TOUR_TITLES,
exitTourContent: EXIT_TOUR_CONTENT,
feedbackContent: FEEDBACK_CONTENT,
dntExitTourContent: DNT_EXIT_TOUR_CONTENT,
goldenTanukiSvgPath,
},
});
},
});
}
import Cookies from 'js-cookie';
import * as types from './mutation_types';
import { ONBOARDING_DISMISSED_COOKIE_NAME } from '../../constants';
import onboardingUtils from '../../utils';
export const setInitialData = ({ commit }, data) => {
commit(types.SET_INITIAL_DATA, data);
};
export const setTourKey = ({ commit }, tourKey) => {
commit(types.SET_TOUR_KEY, tourKey);
onboardingUtils.updateLocalStorage({ tourKey });
};
export const setLastStepIndex = ({ commit }, lastStepIndex) => {
commit(types.SET_LAST_STEP_INDEX, lastStepIndex);
onboardingUtils.updateLocalStorage({ lastStepIndex });
};
export const setHelpContentIndex = ({ commit }, helpContentIndex) => {
commit(types.SET_HELP_CONTENT_INDEX, helpContentIndex);
};
export const switchTourPart = ({ dispatch }, tourKey) => {
dispatch('setTourKey', tourKey);
dispatch('setLastStepIndex', 0);
dispatch('setHelpContentIndex', 0);
};
export const setTourFeedback = ({ commit }, tourFeedback) => {
commit(types.SET_FEEDBACK, tourFeedback);
};
export const setExitTour = ({ commit }, exitTour) => {
commit(types.SET_EXIT_TOUR, exitTour);
};
export const setDntExitTour = ({ commit }, dntExitTour) => {
commit(types.SET_DNT_EXIT_TOUR, dntExitTour);
};
export const setDismissed = ({ commit }, dismissed) => {
commit(types.SET_DISMISSED, dismissed);
Cookies.set(ONBOARDING_DISMISSED_COOKIE_NAME, dismissed);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const stepIndex = state => {
const { tourData, tourKey, url, projectFullPath, createdProjectPath } = state;
let idx = -1;
if (tourData && tourData[tourKey] && url !== '') {
idx = tourData[tourKey].findIndex(item =>
item.forUrl({ projectFullPath, createdProjectPath }).test(state.url),
);
}
return idx !== -1 ? idx : null;
};
export const stepContent = (state, getters) => {
const { tourData, tourKey } = state;
if (!tourData || !tourData[tourKey] || getters.stepIndex === null) {
return null;
}
return tourData[tourKey][getters.stepIndex] ? tourData[tourKey][getters.stepIndex] : null;
};
export const helpContent = (state, getters) => {
const { projectName, helpContentIndex } = state;
if (getters.stepContent === null) {
return null;
}
return getters.stepContent.getHelpContent
? getters.stepContent.getHelpContent({ projectName })[helpContentIndex]
: null;
};
export const totalTourPartSteps = state => {
if (state.tourData && state.tourKey && state.tourData[state.tourKey]) {
return state.tourData[state.tourKey].length;
}
return 0;
};
export const percentageCompleted = state => {
const { tourData, tourKey, lastStepIndex } = state;
if (lastStepIndex === -1 || !tourData || !tourData[tourKey]) {
return 0;
}
return Math.floor((100 * lastStepIndex) / tourData[tourKey].length);
};
export const actionPopover = (state, getters) =>
getters.stepContent !== null && getters.stepContent.actionPopover
? getters.stepContent.actionPopover
: null;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
const createStore = () =>
new Vuex.Store({
actions,
getters,
mutations,
state: state(),
});
export default createStore;
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const SET_TOUR_KEY = 'SET_TOUR_KEY';
export const SET_LAST_STEP_INDEX = 'SET_LAST_STEP_INDEX';
export const SET_HELP_CONTENT_INDEX = 'SET_HELP_CONTENT_INDEX';
export const SET_FEEDBACK = 'SET_FEEDBACK';
export const SET_EXIT_TOUR = 'SET_EXIT_TOUR';
export const SET_DNT_EXIT_TOUR = 'SET_DNT_EXIT_TOUR';
export const SET_DISMISSED = 'SET_DISMISSED';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_DATA](state, payload) {
Object.assign(state, payload);
},
[types.SET_TOUR_KEY](state, payload) {
state.tourKey = payload;
},
[types.SET_LAST_STEP_INDEX](state, payload) {
state.lastStepIndex = payload;
},
[types.SET_HELP_CONTENT_INDEX](state, payload) {
state.helpContentIndex = payload;
},
[types.SET_FEEDBACK](state, payload) {
state.tourFeedback = payload;
},
[types.SET_DNT_EXIT_TOUR](state, payload) {
state.dntExitTour = payload;
},
[types.SET_EXIT_TOUR](state, payload) {
state.exitTour = payload;
},
[types.SET_DISMISSED](state, payload) {
state.dismissed = payload;
},
};
import { AVAILABLE_TOURS } from '../../constants';
export default () => ({
url: '',
projectFullPath: '',
projectName: '',
tourData: [],
tourKey: AVAILABLE_TOURS.GUIDED_GITLAB_TOUR,
helpContentIndex: 0,
lastStepIndex: -1,
dismissed: false,
createdProjectPath: '',
exitTour: false,
tourFeedback: false,
dntExitTour: false,
});
<script>
import { GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import HelpContentPopover from '../../onboarding_helper/components/help_content_popover.vue';
import ActionPopover from '../../onboarding_helper/components/action_popover.vue';
import { redirectTo } from '~/lib/utils/url_utility';
import onboardingUtils from '../../utils';
export default {
components: {
GlLink,
UserAvatarImage,
HelpContentPopover,
ActionPopover,
},
props: {
userAvatarUrl: {
type: String,
required: false,
default: '',
},
projectFullPath: {
type: String,
required: true,
},
skipUrl: {
type: String,
required: true,
},
fromHelpMenu: {
type: Boolean,
required: true,
},
},
data() {
return {
helpText: __(
"Don't worry, you can access this tour by clicking on the help icon in the top right corner and choose <strong>Learn GitLab</strong>.",
),
helpPopover: {
target: null,
content: {
text: __('White helpers give contextual information.'),
buttons: [{ text: __('OK'), btnClass: 'btn-primary', readOnly: true }],
},
},
actionPopover: {
target: null,
content: __('Blue helpers indicate an action to be taken.'),
cssClasses: ['blue'],
},
};
},
computed: {
skipText() {
return this.fromHelpMenu ? __('No, not interested right now') : __('Skip this for now');
},
},
mounted() {
this.helpPopover.target = this.$refs.helpPopoverTrigger;
this.actionPopover.target = this.$refs.actionPopoverTrigger;
},
methods: {
startTour() {
onboardingUtils.resetOnboardingLocalStorage();
onboardingUtils.updateOnboardingDismissed(false);
redirectTo(this.projectFullPath);
},
skipTour() {
onboardingUtils.updateOnboardingDismissed(true);
redirectTo(this.skipUrl);
},
},
};
</script>
<template>
<div class="onboarding-welcome-page content col-lg-6 ml-auto mr-auto">
<div class="text-center">
<user-avatar-image
:img-src="userAvatarUrl"
:size="64"
css-classes="ml-auto mr-auto"
class="d-inline-block"
/>
<h1>{{ __('Hello there') }}</h1>
<p class="large">{{ __('Welcome to the Guided GitLab Tour') }}</p>
</div>
<p class="mt-4">
{{
__(
'We created a short guided tour that will help you learn the basics of GitLab and how it will help you be better at your job. It should only take a couple of minutes. You will be guided by two types of helpers, best recognized by their color.',
)
}}
</p>
<div class="text-center mt-4 mb-4">
<div
id="js-popover-container"
class="popover-container d-flex justify-content-around align-items-end mb-8"
>
<button ref="helpPopoverTrigger" type="button" class="btn-link btn-disabled"></button>
<button
ref="actionPopoverTrigger"
type="button"
class="btn-link btn-disabled mb-3"
></button>
<help-content-popover
v-if="helpPopover.target"
:target="helpPopover.target"
:help-content="helpPopover.content"
placement="top"
container="js-popover-container"
show
/>
<action-popover
v-if="actionPopover.target"
:target="actionPopover.target"
:content="actionPopover.content"
:css-classes="actionPopover.cssClasses"
placement="top"
container="js-popover-container"
show-default
/>
</div>
<gl-link class="qa-start-tour-btn btn btn-success" @click="startTour">
{{ __("Ok let's go") }}
</gl-link>
<p class="small mt-8">
<gl-link class="qa-skip-tour-btn" data-qa-selector="skip_for_now_link" @click="skipTour">
{{ skipText }}
</gl-link>
</p>
<p class="small ml-4 mr-4" v-html="helpText"></p>
</div>
</div>
</template>
<style scoped>
.popover-container {
height: 140px;
}
p.large {
font-size: 16px;
}
p.small {
font-size: 12px;
}
.btn-success {
width: 200px;
}
</style>
import Vue from 'vue';
import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils';
import WelcomePage from './components/welcome_page.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import onboardingUtils from '../utils';
export default function() {
const el = document.getElementById('js-onboarding-welcome');
if (!el) {
return false;
}
const { userAvatarUrl, projectFullPath, skipUrl, fromHelpMenu } = el.dataset;
if (!breakpointInstance.isDesktop()) {
onboardingUtils.updateOnboardingDismissed(true);
return redirectTo(skipUrl);
}
return new Vue({
el,
render(h) {
return h(WelcomePage, {
props: {
userAvatarUrl,
projectFullPath,
skipUrl,
fromHelpMenu: parseBoolean(fromHelpMenu),
},
});
},
});
}
This diff is collapsed.
import Cookies from 'js-cookie';
import AccessorUtilities from '~/lib/utils/accessor';
import {
ONBOARDING_DISMISSED_COOKIE_NAME,
STORAGE_KEY,
ONBOARDING_PROPS_DEFAULTS,
} from './constants';
const isOnboardingDismissed = () => Cookies.get(ONBOARDING_DISMISSED_COOKIE_NAME) === 'true';
const updateOnboardingDismissed = dismissed => {
Cookies.set(ONBOARDING_DISMISSED_COOKIE_NAME, dismissed);
if (dismissed && AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.removeItem(STORAGE_KEY);
}
};
const resetOnboardingLocalStorage = () => {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ONBOARDING_PROPS_DEFAULTS));
}
};
const getOnboardingLocalStorageState = () => {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
return JSON.parse(localStorage.getItem(STORAGE_KEY));
}
return ONBOARDING_PROPS_DEFAULTS;
};
const updateLocalStorage = updatedProps => {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
let currentState = getOnboardingLocalStorageState();
if (!currentState) {
currentState = resetOnboardingLocalStorage();
}
const onboardingState = {
...currentState,
...updatedProps,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(onboardingState));
}
};
const onboardingUtils = {
isOnboardingDismissed,
updateOnboardingDismissed,
resetOnboardingLocalStorage,
getOnboardingLocalStorageState,
updateLocalStorage,
};
export default onboardingUtils;
import initOnboardingWelcome from 'ee/onboarding/onboarding_welcome';
document.addEventListener('DOMContentLoaded', () => initOnboardingWelcome());
import '~/pages/projects/new/index'; import '~/pages/projects/new/index';
import initCustomProjectTemplates from 'ee/projects/custom_project_templates'; import initCustomProjectTemplates from 'ee/projects/custom_project_templates';
import { bindOnboardingEvents } from 'ee/onboarding/new_project';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initCustomProjectTemplates(); initCustomProjectTemplates();
bindOnboardingEvents(document.getElementById('new_project'));
}); });
.container.container-limited.limit-container-width.navless-container
#js-onboarding-welcome{ data: { user_avatar_url: avatar_icon_for_user(current_user), project_full_path: @project.web_url, skip_url: root_dashboard_path(nav_source: "onboarding"), from_help_menu: params[:from_help_menu] } }
- page_title _("Onboarding")
- header_title _("Onboarding")
%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
%body.navless
= yield
- return unless user_onboarding_enabled?
%li.d-none.d-lg-block
%a{ href: explore_onboarding_index_path(from_help_menu: true) }
= _("Learn GitLab")
%span.badge.badge-success= s_("Badge|New")
- onboarding_project = session[:onboarding_project]
- return unless onboarding_project
#js-onboarding-helper{ data: { project_full_path: onboarding_project[:project_full_path], project_name: onboarding_project[:project_name], golden_tanuki_svg_path: image_path('illustrations/golden_tanuki.svg') } }
#js-onboarding-action-popover
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Explore::OnboardingController do
let(:user) { create(:user, username: 'gitlab-org') }
before do
sign_in(user)
end
shared_examples_for 'when the feature is enabled' do
before do
stub_feature_flags(user_onboarding: true)
project.add_guest(user)
end
context 'feature enabled' do
it 'renders index with 200 status code and sets the session variable if the user is authenticated' do
get :index
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
expect(session[:onboarding_project]).to eq({ project_full_path: project.web_url, project_name: project.name })
end
end
context 'when the feature is disabled' do
before do
stub_feature_flags(user_onboarding: false)
end
it 'returns 404' do
get :index
expect(response).to have_gitlab_http_status(:not_found)
expect(session[:onboarding_project]).to be_nil
end
end
end
context 'when on .com' do
describe 'GET #index' do
before do
allow(Gitlab).to receive(:com?) { true }
end
it_behaves_like 'when the feature is enabled' do
let(:project) { create(:project, path: 'gitlab-foss', namespace: user.namespace) }
end
end
end
context 'is dev env' do
describe 'GET #index' do
before do
allow(Gitlab).to receive(:com?) { false }
allow(Gitlab).to receive(:dev_env_or_com?) { true }
end
it_behaves_like 'when the feature is enabled' do
let(:project) { create(:project, path: 'gitlab-test', namespace: user.namespace) }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User Onboarding' do
include MobileHelpers
let(:user) { create(:user) }
let(:project) { create(:project) }
before do
allow(Gitlab).to receive(:com?) { true }
sign_in(user)
end
context 'when the feature is enabled', :js do
before do
stub_feature_flags(user_onboarding: true)
end
describe 'help menu' do
before do
visit root_dashboard_path
find('.header-help-dropdown-toggle').click
end
it 'shows the "Learn GitLab" item in the help menu' do
page.within('.header-help') do
expect(page).to have_link('Learn GitLab', href: explore_onboarding_index_path(from_help_menu: true))
end
end
context 'when on a mobile device' do
before do
resize_screen_sm
end
it 'does not show the "Learn GitLab" item in the help menu' do
page.within('.header-user') do
expect(page).not_to have_link('Learn GitLab')
end
end
end
end
describe 'welcome page' do
before do
allow(Project).to receive(:find_by_full_path).and_return(project)
project.add_guest(user)
end
it 'shows the "Learn GitLab" welcome page' do
visit explore_onboarding_index_path
expect(page).to have_content('Welcome to the Guided GitLab Tour')
end
context 'when on a mobile device' do
before do
resize_screen_sm
end
it 'does not show the "Learn GitLab" welcome page' do
visit explore_onboarding_index_path
expect(page).not_to have_content('Welcome to the Guided GitLab Tour')
end
end
end
describe 'onboarding helper' do
before do
allow(Project).to receive(:find_by_full_path).and_return(project)
project.add_guest(user)
end
it 'shows the onboarding helper on the onboarding project' do
visit explore_onboarding_index_path
find('.btn-success').click
expect(page).to have_css('#js-onboarding-helper', visible: true)
end
end
end
context 'when the feature is disabled' do
before do
stub_feature_flags(user_onboarding: false)
end
describe 'help menu' do
it 'does not show the "Learn GitLab" item in the help menu' do
visit root_dashboard_path
find('.header-help-dropdown-toggle').click
page.within('.header-help') do
expect(page).not_to have_link('Learn GitLab')
end
end
end
describe 'welcome page' do
it 'does not show the "Learn GitLab" welcome page' do
visit explore_onboarding_index_path
expect(page).not_to have_content('Welcome to the Guided GitLab Tour')
end
end
describe 'onboarding helper' do
it 'does not show the onboarding helper on the onboarding project' do
visit project_path(project)
expect(page).not_to have_css('#js-onboarding-helper')
end
end
end
end
import { bindOnboardingEvents, getProjectPath } from 'ee/onboarding/new_project';
import onboardingUtils from 'ee/onboarding/utils';
import { AVAILABLE_TOURS } from 'ee/onboarding/constants';
import { TEST_HOST } from 'helpers/test_constants';
import { setHTMLFixture } from 'helpers/fixtures';
describe('User onboarding new project utils', () => {
describe('getProjectPath', () => {
describe('when there exists a namespace select', () => {
beforeEach(() => {
setHTMLFixture(`
<div class='active tab-pane js-toggle-container'>
<input id="project_path" value="my-project"/>
<select class="js-select-namespace">
<option data-show-path="${TEST_HOST}/MyPath" selected="selected">MyPath</option>
<option data-show-path="${TEST_HOST}/foobar">foobar</option>
</select>
</div>
`);
});
it('returns the namespace and path', () => {
const result = getProjectPath();
expect(result).toEqual(`${TEST_HOST}/MyPath/my-project`);
});
});
describe("when there doesn't exist a namespace select", () => {
beforeEach(() => {
setHTMLFixture(`
<div class='active tab-pane js-toggle-container'>
<input id="project_path" value="my-project"/>
</div>
`);
});
it('returns the path only if there is no namespace select', () => {
const result = getProjectPath();
expect(result).toEqual('my-project');
});
});
});
describe('bindOnboardingEvents', () => {
let form;
let submitBtn;
let submitSpy;
beforeEach(() => {
setHTMLFixture(`
<div class='active tab-pane js-toggle-container'>
<form id="new_project">
<input id="project_path" value="my-project"/>
<input id="submitBtn" type="submit" value="Create project">
</form>
</div>
`);
submitSpy = jest
.fn()
.mockName('submit')
.mockImplementation(event => event.preventDefault());
form = document.getElementById('new_project');
submitBtn = document.getElementById('submitBtn');
form.addEventListener('submit', submitSpy);
jest.spyOn(form, 'submit');
});
describe('when onboarding is not dismissed and there is an onboarding state on the local storage', () => {
beforeEach(() => {
jest.spyOn(onboardingUtils, 'isOnboardingDismissed').mockReturnValue(false);
jest.spyOn(onboardingUtils, 'getOnboardingLocalStorageState').mockReturnValue({
tourKey: AVAILABLE_TOURS.CREATE_PROJECT_TOUR,
});
});
it('adds the submit event listener to the form', () => {
jest.spyOn(form, 'addEventListener');
bindOnboardingEvents(form);
expect(form.addEventListener).toHaveBeenCalledWith('submit', expect.any(Function));
});
it('calls updateLocalStorage with the correct project path when the form is submitted', () => {
jest.spyOn(onboardingUtils, 'updateLocalStorage');
bindOnboardingEvents(form);
submitBtn.click();
expect(onboardingUtils.updateLocalStorage).toHaveBeenCalledWith({
createdProjectPath: 'my-project',
});
});
});
describe('when onboarding is dismissed', () => {
beforeEach(() => {
jest.spyOn(onboardingUtils, 'isOnboardingDismissed').mockReturnValue(true);
});
it('does not add the submit event listener to the form', () => {
jest.spyOn(form, 'addEventListener');
bindOnboardingEvents(form);
expect(form.addEventListener).not.toHaveBeenCalled();
});
it('does not call updateLocalStorage when the form is submitted', () => {
jest.spyOn(onboardingUtils, 'updateLocalStorage');
bindOnboardingEvents(form);
submitBtn.click();
expect(onboardingUtils.updateLocalStorage).not.toHaveBeenCalled();
});
});
describe('when the user is currently on a tour part different from the "Create Project Tour"', () => {
beforeEach(() => {
jest.spyOn(onboardingUtils, 'isOnboardingDismissed').mockReturnValue(false);
jest.spyOn(onboardingUtils, 'getOnboardingLocalStorageState').mockReturnValue({
tourKey: AVAILABLE_TOURS.GITLAB_GUIDED_TOUR,
});
});
it('does not add the submit event listener to the form', () => {
jest.spyOn(form, 'addEventListener');
bindOnboardingEvents(form);
expect(form.addEventListener).not.toHaveBeenCalled();
});
it('does not call updateLocalStorage when the form is submitted', () => {
jest.spyOn(onboardingUtils, 'updateLocalStorage');
bindOnboardingEvents(form);
submitBtn.click();
expect(onboardingUtils.updateLocalStorage).not.toHaveBeenCalled();
});
});
});
});
import component from 'ee/onboarding/onboarding_helper/components/action_popover.vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import eventHub from 'ee/onboarding/onboarding_helper/event_hub';
const localVue = createLocalVue();
describe('User onboarding action popover', () => {
let wrapper;
let props;
const target = document.createElement('a');
const content = 'This is some test content';
const placement = 'top';
const showDefault = true;
const createComponent = () => {
props = {
target,
content,
placement,
showDefault,
};
wrapper = shallowMount(localVue.extend(component), {
propsData: props,
localVue,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when mounted', () => {
it("binds 'onboardingHelper.showActionPopover', 'onboardingHelper.hideActionPopover' and 'onboardingHelper.destroyActionPopover' event listener on eventHub", () => {
jest.spyOn(eventHub, '$on');
createComponent();
expect(eventHub.$on).toHaveBeenCalledWith(
'onboardingHelper.showActionPopover',
expect.any(Function),
);
expect(eventHub.$on).toHaveBeenCalledWith(
'onboardingHelper.hideActionPopover',
expect.any(Function),
);
expect(eventHub.$on).toHaveBeenCalledWith(
'onboardingHelper.destroyActionPopover',
expect.any(Function),
);
});
});
describe('after mount', () => {
beforeEach(() => {
createComponent();
});
describe('beforeDestroy', () => {
it("unbinds 'showActionPopover', 'hideActionPopover' and 'destroyActionPopover' event handler", () => {
jest.spyOn(eventHub, '$off');
wrapper.destroy();
expect(eventHub.$off).toHaveBeenCalledWith('onboardingHelper.showActionPopover');
expect(eventHub.$off).toHaveBeenCalledWith('onboardingHelper.hideActionPopover');
expect(eventHub.$off).toHaveBeenCalledWith('onboardingHelper.destroyActionPopover');
});
});
describe('methods', () => {
describe('toggleShowPopover', () => {
it('updates the showPopover property', () => {
wrapper.vm.showPopover = false;
wrapper.vm.toggleShowPopover(true);
expect(wrapper.vm.showPopover).toBeTruthy();
});
});
});
describe('template', () => {
it('shows the content passed in as prop', () => {
expect(wrapper.text()).toEqual(content);
});
});
});
});
import component from 'ee/onboarding/onboarding_helper/components/help_content_popover.vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlDeprecatedButton } from '@gitlab/ui';
const localVue = createLocalVue();
describe('User onboarding help content popover', () => {
let wrapper;
const target = document.createElement('a');
const helpContent = {
text: 'some help content',
buttons: [{ text: 'button', btnClass: 'btn-primary' }],
};
const defaultProps = {
target,
helpContent,
placement: 'top',
show: false,
disabled: false,
};
const exitTourContent = {
text: 'some help content',
buttonText: "Close 'Learn GitLab'",
exitTour: true,
};
const exitTourProps = {
...defaultProps,
helpContent: exitTourContent,
};
const feedbackContent = {
text: 'some help content',
feedbackButtons: true,
feedbackSize: 5,
};
const feedbackProps = {
...defaultProps,
helpContent: feedbackContent,
};
function createComponent(propsData) {
wrapper = shallowMount(localVue.extend(component), {
propsData,
localVue,
});
}
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('callStepContentButton', () => {
it('emits clickStepContentButton when called', () => {
createComponent(defaultProps);
wrapper.find('.btn-primary').vm.$emit('click');
expect(wrapper.emittedByOrder()).toEqual([
{ name: 'clickStepContentButton', args: [defaultProps.helpContent.buttons[0]] },
]);
});
});
describe('callExitTour', () => {
it('emits clickExitTourButton when called', () => {
createComponent(exitTourProps);
wrapper.find(GlDeprecatedButton).vm.$emit('click');
expect(wrapper.emittedByOrder()).toEqual([{ name: 'clickExitTourButton', args: [] }]);
});
});
describe('submitFeedback', () => {
it('emits clickFeedbackButton when called', () => {
createComponent(feedbackProps);
wrapper.find(GlDeprecatedButton).vm.$emit('click');
expect(wrapper.emittedByOrder()).toEqual([
{ name: 'clickFeedbackButton', args: [{ feedbackResult: 1 }] },
]);
});
});
});
describe('template', () => {
it('displays the help content text and renders a primary button with the text "button"', () => {
createComponent(defaultProps);
const btn = wrapper.find('.btn-primary');
expect(wrapper.text()).toContain(defaultProps.helpContent.text);
expect(btn.exists()).toBe(true);
expect(btn.text()).toBe(defaultProps.helpContent.buttons[0].text);
});
it('displays the help content text and renders a primary button with exit text when there is no buttons in help content', () => {
createComponent(exitTourProps);
const btn = wrapper.find('.btn-primary');
expect(wrapper.text()).toContain(exitTourProps.helpContent.text);
expect(btn.exists()).toBe(true);
expect(btn.text()).toBe("Close 'Learn GitLab'");
});
it('renders a secondary button with the text "button"', () => {
const propsData = {
...defaultProps,
helpContent: {
...defaultProps.helpContent,
buttons: [{ text: 'button', btnClass: 'btn-secondary' }],
},
};
createComponent(propsData);
const btn = wrapper.find('.btn-secondary');
expect(btn.exists()).toBe(true);
expect(btn.text()).toBe(propsData.helpContent.buttons[0].text);
});
it("does not render any buttons if the help content doesn't contain buttons", () => {
const propsData = {
...defaultProps,
helpContent: {
...defaultProps.helpContent,
buttons: null,
},
};
createComponent(propsData);
const primaryBtn = wrapper.find('.btn-primary');
const secondaryBtn = wrapper.find('.btn-secondary');
expect(primaryBtn.exists()).toBe(false);
expect(secondaryBtn.exists()).toBe(false);
});
it('updates the help content text when props change', () => {
const propsData = {
...defaultProps,
helpContent: {
...defaultProps.helpContent,
text: 'updated text',
},
};
createComponent(propsData);
expect(wrapper.text()).toContain(propsData.helpContent.text);
});
});
});
import component from 'ee/onboarding/onboarding_helper/components/tour_parts_list.vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
const localVue = createLocalVue();
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(localVue.extend(component), {
propsData,
localVue,
});
}
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')).toHaveLength(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);
});
});
});
export const mockTourData = {
1: [
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/foo$`, ''),
getHelpContent: () => [
{
text: 'foo',
buttons: [{ text: 'button', btnClass: 'btn-primary' }],
},
{
text: 'next content item',
buttons: [{ text: 'button', btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '.popup-trigger',
text: 'foo',
placement: 'top',
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/foo/bar$`, ''),
getHelpContent: ({ projectName }) => [
{
text: `This is the ${projectName}`,
buttons: [{ text: 'button', btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '',
text: 'bar',
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/xyz`, ''),
getHelpContent: null,
actionPopover: {
selector: null,
text: 'foo',
placement: 'top',
},
},
],
};
export const mockData = {
url: 'http://gitlab-org/gitlab-test/foo',
projectFullPath: 'http://gitlab-org/gitlab-test',
projectName: 'Mock Project',
tourData: mockTourData,
tourKey: 1,
helpContentIndex: 0,
lastStepIndex: -1,
createdProjectPath: '',
};
import Cookies from 'js-cookie';
import testAction from 'helpers/vuex_action_helper';
import createState from 'ee/onboarding/onboarding_helper/store/state';
import * as types from 'ee/onboarding/onboarding_helper/store/mutation_types';
import {
setInitialData,
setTourKey,
setLastStepIndex,
setHelpContentIndex,
switchTourPart,
setTourFeedback,
setDntExitTour,
setExitTour,
setDismissed,
} from 'ee/onboarding/onboarding_helper/store/actions';
import { ONBOARDING_DISMISSED_COOKIE_NAME } from 'ee/onboarding/constants';
import onboardingUtils from 'ee/onboarding/utils';
import mockData from '../mock_data';
describe('User onboarding helper store actions', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('setInitialData', () => {
it(`commits ${types.SET_INITIAL_DATA} mutation`, done => {
const initialData = mockData;
testAction(
setInitialData,
initialData,
state,
[{ type: types.SET_INITIAL_DATA, payload: initialData }],
[],
done,
);
});
});
describe('setTourKey', () => {
beforeEach(() => {
jest.spyOn(onboardingUtils, 'updateLocalStorage');
});
it(`commits ${types.SET_TOUR_KEY} mutation`, done => {
const tourKey = 2;
testAction(
setTourKey,
tourKey,
state,
[{ type: types.SET_TOUR_KEY, payload: tourKey }],
[],
done,
);
});
it('updates localStorage with the tourKey', () => {
const tourKey = 2;
setTourKey({ commit() {} }, tourKey);
expect(onboardingUtils.updateLocalStorage).toHaveBeenCalledWith({ tourKey });
});
});
describe('setLastStepIndex', () => {
beforeEach(() => {
jest.spyOn(onboardingUtils, 'updateLocalStorage');
});
it(`commits ${types.SET_LAST_STEP_INDEX} mutation`, done => {
const lastStepIndex = 1;
testAction(
setLastStepIndex,
lastStepIndex,
state,
[{ type: types.SET_LAST_STEP_INDEX, payload: lastStepIndex }],
[],
done,
);
});
it('updates localStorage with the lastStepIndex', () => {
const lastStepIndex = 1;
setLastStepIndex({ commit() {} }, lastStepIndex);
expect(onboardingUtils.updateLocalStorage).toHaveBeenCalledWith({ lastStepIndex });
});
});
describe('setHelpContentIndex', () => {
it(`commits ${types.SET_HELP_CONTENT_INDEX} mutation`, done => {
const helpContentIndex = 1;
testAction(
setHelpContentIndex,
helpContentIndex,
state,
[{ type: types.SET_HELP_CONTENT_INDEX, payload: helpContentIndex }],
[],
done,
);
});
});
describe('switchTourPart', () => {
it('should dispatch setTourKey, setLastStepIndex and', done => {
const nextPart = 2;
testAction(
switchTourPart,
nextPart,
state,
[],
[
{ type: 'setTourKey', payload: nextPart },
{ type: 'setLastStepIndex', payload: 0 },
{ type: 'setHelpContentIndex', payload: 0 },
],
done,
);
});
});
describe('setExitTour', () => {
it(`commits ${types.SET_EXIT_TOUR} mutation`, done => {
const exitTour = true;
testAction(
setExitTour,
exitTour,
state,
[{ type: types.SET_EXIT_TOUR, payload: exitTour }],
[],
done,
);
});
});
describe('setTourFeedback', () => {
it(`commits ${types.SET_FEEDBACK} mutation`, done => {
const tourFeedback = true;
testAction(
setTourFeedback,
tourFeedback,
state,
[{ type: types.SET_FEEDBACK, payload: tourFeedback }],
[],
done,
);
});
});
describe('setDntExitTour', () => {
it(`commits ${types.SET_DNT_EXIT_TOUR} mutation`, done => {
const dntExitTour = true;
testAction(
setDntExitTour,
dntExitTour,
state,
[{ type: types.SET_DNT_EXIT_TOUR, payload: dntExitTour }],
[],
done,
);
});
});
describe('setDismissed', () => {
it(`commits ${types.SET_DISMISSED} mutation`, done => {
const dismissed = true;
testAction(
setDismissed,
dismissed,
state,
[{ type: types.SET_DISMISSED, payload: dismissed }],
[],
() => {
setImmediate(() => {
expect(Cookies.get(ONBOARDING_DISMISSED_COOKIE_NAME)).toEqual(`${dismissed}`);
done();
});
},
);
});
});
});
import * as getters from 'ee/onboarding/onboarding_helper/store/getters';
import createStore from 'ee/onboarding/onboarding_helper/store/state';
import { mockTourData } from '../mock_data';
describe('User onboarding store getters', () => {
let localState;
beforeEach(() => {
localState = createStore();
localState.projectFullPath = 'http://gitlab-org/gitlab-test';
localState.tourData = mockTourData;
localState.tourKey = 1;
localState.url = 'http://gitlab-org/gitlab-test/foo/bar';
});
describe('stepIndex', () => {
it('returns the current step index if the url matches the data at a given tour key', () => {
expect(getters.stepIndex(localState)).toBe(1);
});
it('returns null if there is no tour data', () => {
localState.tourData = [];
expect(getters.stepIndex(localState)).toBe(null);
});
it('returns null if there is no tour key', () => {
localState.tourKey = null;
expect(getters.stepIndex(localState)).toBe(null);
});
it("returns null if the url doesn't match any data at a given tour key", () => {
localState.url = 'http://not-matching/url';
expect(getters.stepIndex(localState)).toBe(null);
});
it("returns null if the url doesn't match any data due to a different project full path", () => {
localState.projectFullPath = 'http://my-path/does/not/match';
expect(getters.stepIndex(localState)).toBe(null);
});
});
describe('stepContent', () => {
it('returns the correct step content for the active tour step', () => {
const tourKey = 1;
const stepIndex = 1;
const localGetters = {
stepIndex,
};
expect(getters.stepContent(localState, localGetters)).toBe(mockTourData[tourKey][stepIndex]);
});
it('returns null if there is no tour data', () => {
localState.tourData = [];
const localGetters = {
stepIndex: 1,
};
expect(getters.stepContent(localState, localGetters)).toBe(null);
});
it('returns null if there is no step index', () => {
const localGetters = {
stepIndex: null,
};
expect(getters.stepContent(localState, localGetters)).toBe(null);
});
});
describe('helpContent', () => {
it('returns the help content for a given index', () => {
const helpContentIndex = 0;
const stepContent = {
getHelpContent: () => [
{
text: 'foo',
buttons: [{ text: 'button', btnClass: 'btn-primary' }],
},
],
};
const localGetters = {
stepContent,
};
localState.helpContentIndex = helpContentIndex;
expect(getters.helpContent(localState, localGetters)).toEqual(
stepContent.getHelpContent()[helpContentIndex],
);
});
it('displays the project name in the help content text', () => {
const helpContentIndex = 0;
const stepContent = {
getHelpContent: ({ projectName }) => [
{
text: `This is the ${projectName}`,
buttons: [{ text: 'button', btnClass: 'btn-primary' }],
},
],
};
const localGetters = {
stepContent,
};
localState.helpContentIndex = helpContentIndex;
localState.projectName = 'Mock Project';
const helpContent = getters.helpContent(localState, localGetters);
expect(helpContent.text).toBe('This is the Mock Project');
});
it('returns null if there is no step content', () => {
const localGetters = {
stepContent: null,
};
localState.helpContentIndex = 0;
expect(getters.helpContent(localState, localGetters)).toBe(null);
});
it('returns null if there is no getHelpContent property on the step content', () => {
const stepContent = {
getHelpContent: null,
};
const localGetters = {
stepContent,
};
expect(getters.helpContent(localState, localGetters)).toBe(null);
});
});
describe('totalTourPartSteps', () => {
it('returns the correct number of total tour steps for the tour with key "1"', () => {
expect(getters.totalTourPartSteps(localState)).toBe(3);
});
it('returns 0 if there is no tour data', () => {
localState.tourData = [];
expect(getters.totalTourPartSteps(localState)).toBe(0);
});
it('returns 0 if there is no tour key', () => {
localState.tourKey = null;
expect(getters.totalTourPartSteps(localState)).toBe(0);
});
it('returns 0 if there is no data at a given tour key', () => {
localState.tourKey = 10;
expect(getters.totalTourPartSteps(localState)).toBe(0);
});
});
describe('percentageCompleted', () => {
it('returns the percentage completed for the current step', () => {
localState.lastStepIndex = 1;
expect(getters.percentageCompleted(localState)).toBe(33);
});
it('returns the 0 if there is no step index', () => {
const localGetters = {
stepIndex: null,
};
expect(getters.percentageCompleted(localState, localGetters)).toBe(0);
});
it('returns the 0 if there is no data for a given step index', () => {
const localGetters = {
stepIndex: 10,
};
expect(getters.percentageCompleted(localState, localGetters)).toBe(0);
});
});
describe('actionPopover', () => {
it("returns the step content's action popover if the step content exists", () => {
const stepContent = {
actionPopover: {
selector: '.popover-selector',
text: 'Some action popover content',
},
};
const localGetters = {
stepContent,
};
expect(getters.actionPopover(localState, localGetters)).toEqual(stepContent.actionPopover);
});
it('returns null if there is no step content', () => {
const localGetters = {
stepContent: null,
};
expect(getters.actionPopover(localState, localGetters)).toBeNull();
});
});
});
import createState from 'ee/onboarding/onboarding_helper/store/state';
import mutations from 'ee/onboarding/onboarding_helper/store/mutations';
import * as types from 'ee/onboarding/onboarding_helper/store/mutation_types';
import { mockTourData } from '../mock_data';
describe('User onboarding helper store mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('SET_INITIAL_DATA', () => {
it('sets all inital data', () => {
const initialData = {
url: 'http://gitlab-org/gitlab-test/foo',
projectFullPath: 'http://gitlab-org/gitlab-test',
projectName: 'Mock Project',
tourData: mockTourData,
tourKey: 1,
helpContentIndex: 0,
lastStepIndex: -1,
dismissed: false,
createdProjectPath: '',
exitTour: false,
tourFeedback: false,
dntExitTour: false,
};
mutations[types.SET_INITIAL_DATA](state, initialData);
expect(state).toEqual(initialData);
});
});
describe('SET_TOUR_KEY', () => {
it('sets the tour key', () => {
const tourKey = 2;
mutations[types.SET_TOUR_KEY](state, tourKey);
expect(state.tourKey).toEqual(tourKey);
});
});
describe('SET_LAST_STEP_INDEX', () => {
it('sets the last step index', () => {
const lastStepIndex = 1;
mutations[types.SET_LAST_STEP_INDEX](state, lastStepIndex);
expect(state.lastStepIndex).toEqual(lastStepIndex);
});
});
describe('SET_HELP_CONTENT_INDEX', () => {
it('sets the help content index', () => {
const helpContentIndex = 1;
mutations[types.SET_HELP_CONTENT_INDEX](state, helpContentIndex);
expect(state.helpContentIndex).toEqual(helpContentIndex);
});
});
describe('SET_EXIT_TOUR', () => {
it('sets the exitTour property to true', () => {
mutations[types.SET_EXIT_TOUR](state, true);
expect(state.exitTour).toBeTruthy();
});
});
describe('SET_FEEDBACK', () => {
it('sets the tourFeedback property to true', () => {
mutations[types.SET_FEEDBACK](state, true);
expect(state.tourFeedback).toBeTruthy();
});
});
describe('SET_DNT_EXIT_TOUR', () => {
it('sets the dntExitTour property to true', () => {
mutations[types.SET_DNT_EXIT_TOUR](state, true);
expect(state.dntExitTour).toBeTruthy();
});
});
describe('SET_DISMISSED', () => {
it('sets the dismissed property to true', () => {
mutations[types.SET_DISMISSED](state, true);
expect(state.dismissed).toBeTruthy();
});
});
});
import Vue from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import component from 'ee/onboarding/onboarding_welcome/components/welcome_page.vue';
import ActionPopover from 'ee/onboarding/onboarding_helper/components/action_popover.vue';
import HelpContentPopover from 'ee/onboarding/onboarding_helper/components/help_content_popover.vue';
import onboardingUtils from 'ee/onboarding/utils';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
const localVue = createLocalVue();
describe('User onboarding welcome page', () => {
let wrapper;
const props = {
userAvatarUrl: 'my-user.avatar.com',
projectFullPath: 'my-dummy-project/path',
skipUrl: 'skip.url.com',
fromHelpMenu: false,
};
function createComponent(propsData) {
wrapper = shallowMount(localVue.extend(component), {
propsData,
localVue,
});
}
afterEach(() => {
wrapper.destroy();
});
beforeEach(done => {
createComponent(props);
Vue.nextTick(done);
});
const findSkipBtn = () => wrapper.find('.qa-skip-tour-btn');
describe('methods', () => {
describe('startTour', () => {
it('resets the localStorage', done => {
jest.spyOn(onboardingUtils, 'resetOnboardingLocalStorage');
wrapper.vm
.$nextTick()
.then(() => wrapper.vm.startTour())
.then(wrapper.vm.$nextTick)
.then(() => {
expect(onboardingUtils.resetOnboardingLocalStorage).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('sets the dismissed property to false', done => {
jest.spyOn(onboardingUtils, 'updateOnboardingDismissed');
wrapper.vm
.$nextTick()
.then(() => wrapper.vm.startTour())
.then(wrapper.vm.$nextTick)
.then(() => {
expect(onboardingUtils.updateOnboardingDismissed).toHaveBeenCalledWith(false);
})
.then(done)
.catch(done.fail);
});
it('redirects to the project path', done => {
wrapper.vm
.$nextTick()
.then(() => wrapper.vm.startTour())
.then(wrapper.vm.$nextTick)
.then(() => {
expect(redirectTo).toHaveBeenCalledWith(props.projectFullPath);
})
.then(done)
.catch(done.fail);
});
});
describe('skipTour', () => {
it('sets the dismissed property to true', done => {
jest.spyOn(onboardingUtils, 'updateOnboardingDismissed');
wrapper.vm
.$nextTick()
.then(() => wrapper.vm.skipTour())
.then(wrapper.vm.$nextTick)
.then(() => {
expect(onboardingUtils.updateOnboardingDismissed).toHaveBeenCalledWith(true);
})
.then(done)
.catch(done.fail);
});
it('redirects to the skip url', done => {
wrapper.vm
.$nextTick()
.then(() => wrapper.vm.skipTour())
.then(wrapper.vm.$nextTick)
.then(() => {
expect(redirectTo).toHaveBeenCalledWith(props.skipUrl);
})
.then(done)
.catch(done.fail);
});
});
});
describe('template', () => {
it('renders the user avatar', () => {
const userAvatarImage = wrapper.find(UserAvatarImage);
expect(userAvatarImage.exists()).toBe(true);
expect(userAvatarImage.props('imgSrc')).toEqual(props.userAvatarUrl);
});
it('displays the title', () => {
expect(wrapper.text()).toContain('Hello there');
});
it('displays the subtitle', () => {
expect(wrapper.text()).toContain('Welcome to the Guided GitLab Tour');
});
it('displays the welcome text', () => {
expect(wrapper.text()).toContain(
'We created a short guided tour that will help you learn the basics of GitLab and how it will help you be better at your job. It should only take a couple of minutes. You will be guided by two types of helpers, best recognized by their color.',
);
});
it('displays the help content popover', () => {
const helpContentPopover = wrapper.find(HelpContentPopover);
expect(helpContentPopover.exists()).toBe(true);
expect(helpContentPopover.props('helpContent').text).toEqual(
'White helpers give contextual information.',
);
});
it('displays the action popover', () => {
const actionPopover = wrapper.find(ActionPopover);
expect(actionPopover.exists()).toBe(true);
expect(actionPopover.props('content')).toEqual(
'Blue helpers indicate an action to be taken.',
);
});
it('displays the "Ok let\'s got" button', () => {
const btn = wrapper.find('.qa-start-tour-btn');
expect(btn.exists()).toBe(true);
expect(btn.text()).toContain("Ok let's go");
});
it('displays "Skip this for now" as link text if fromHelpMenu is false', () => {
expect(findSkipBtn().exists()).toBe(true);
expect(findSkipBtn().text()).toContain('Skip this for now');
});
it('displays "No, not interested right now" as link text if fromHelpMenu is true', () => {
const propsData = {
...props,
fromHelpMenu: true,
};
createComponent(propsData);
expect(findSkipBtn().exists()).toBe(true);
expect(findSkipBtn().text()).toContain('No, not interested right now');
});
it('displays a note on how users can start the tour from the help menu', () => {
expect(wrapper.text()).toContain(
"Don't worry, you can access this tour by clicking on the help icon in the top right corner and choose Learn GitLab.",
);
});
});
});
import Cookies from 'js-cookie';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import {
ONBOARDING_DISMISSED_COOKIE_NAME,
STORAGE_KEY,
ONBOARDING_PROPS_DEFAULTS,
} from 'ee/onboarding/constants';
import onboardingUtils from 'ee/onboarding/utils';
import AccessorUtilities from '~/lib/utils/accessor';
describe('User onboarding utils', () => {
useLocalStorageSpy();
beforeEach(() => {
Cookies.remove(ONBOARDING_DISMISSED_COOKIE_NAME);
onboardingUtils.resetOnboardingLocalStorage();
jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true);
});
describe('isOnboardingDismissed', () => {
it('return true if the cookie value is true', () => {
Cookies.set(ONBOARDING_DISMISSED_COOKIE_NAME, true);
expect(onboardingUtils.isOnboardingDismissed()).toBe(true);
});
it('return false if the cookie is not set', () => {
expect(onboardingUtils.isOnboardingDismissed()).toBe(false);
});
});
describe('updateOnboardingDismissed', () => {
it('set the dismissed state on the cookie', () => {
onboardingUtils.updateOnboardingDismissed(true);
expect(Cookies.get(ONBOARDING_DISMISSED_COOKIE_NAME)).toBe('true');
});
it('removes onboarding related data from localStorage', () => {
onboardingUtils.updateOnboardingDismissed(true);
expect(localStorage.removeItem).toHaveBeenCalledWith(STORAGE_KEY);
});
});
describe('resetOnboardingLocalStorage', () => {
it('resets the onboarding props in the localStorage to the default', () => {
jest.spyOn(window.localStorage, 'setItem');
onboardingUtils.resetOnboardingLocalStorage();
expect(localStorage.setItem).toHaveBeenCalledWith(
STORAGE_KEY,
JSON.stringify(ONBOARDING_PROPS_DEFAULTS),
);
});
});
describe('getOnboardingLocalStorageState', () => {
it('retrieves the proper values from localStorage', () => {
jest.spyOn(window.localStorage, 'getItem').mockReturnValue('{}');
onboardingUtils.getOnboardingLocalStorageState();
expect(localStorage.getItem).toHaveBeenCalledWith(STORAGE_KEY);
});
});
describe('updateLocalStorage', () => {
it('updates the onboarding state on the localStorage', () => {
jest.spyOn(window.localStorage, 'getItem').mockReturnValue('{}');
jest.spyOn(window.localStorage, 'setItem');
const modified = {
tourKey: 2,
lastStepIndex: 5,
createdProjectPath: 'foo',
};
onboardingUtils.updateLocalStorage(modified);
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, JSON.stringify(modified));
});
});
});
This diff is collapsed.
...@@ -49,8 +49,6 @@ module QA ...@@ -49,8 +49,6 @@ module QA
module Main module Main
autoload :Banner, 'qa/ee/page/main/banner' autoload :Banner, 'qa/ee/page/main/banner'
autoload :Login, 'qa/ee/page/main/login'
autoload :Onboarding, 'qa/ee/page/main/onboarding'
end end
module Admin module Admin
......
# frozen_string_literal: true
module QA
module EE
module Page
module Main
module Login
extend QA::Page::PageConcern
def skip_onboarding
Page::Main::Onboarding.perform(&:skip_if_visible)
end
end
end
end
end
end
# frozen_string_literal: true
module QA
module EE
module Page
module Main
class Onboarding < QA::Page::Base
view 'ee/app/assets/javascripts/onboarding/onboarding_welcome/components/welcome_page.vue' do
element :skip_for_now_link, required: true
end
def skip_if_visible
click_skip_for_now_link if visible?
end
def click_skip_for_now_link
click_element :skip_for_now_link, ::QA::Page::Main::Menu
end
end
end
end
end
end
...@@ -165,8 +165,6 @@ module QA ...@@ -165,8 +165,6 @@ module QA
terms.accept_terms if terms.visible? terms.accept_terms if terms.visible?
end end
skip_onboarding if respond_to?(:skip_onboarding)
Page::Main::Menu.validate_elements_present! unless skip_page_validation Page::Main::Menu.validate_elements_present! unless skip_page_validation
end end
...@@ -181,5 +179,3 @@ module QA ...@@ -181,5 +179,3 @@ module QA
end end
end end
end end
QA::Page::Main::Login.prepend_if_ee('QA::EE::Page::Main::Login')
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