Commit 946fa9ae authored by Denys Mishunov's avatar Denys Mishunov

Merge branch '219150-remove-onboarding-tour-fe' into 'master'

Remove OnBoarding tour - FE

See merge request gitlab-org/gitlab!35442
parents d85207d8 04109f4b
...@@ -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;
......
...@@ -549,41 +549,6 @@ img.emoji { ...@@ -549,41 +549,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),
},
});
},
});
}
import { s__, sprintf } from '~/locale';
import { LABEL_SEARCH_QUERY, ACCEPTING_MR_LABEL_TEXT, AVAILABLE_TOURS } from './constants';
const GUIDED_GITLAB_TOUR = [
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}$`, ''),
getHelpContent: ({ projectName }) => [
{
text: sprintf(
s__(
'UserOnboardingTour|Welcome to the project overview of the %{emphasisStart}%{projectName}%{emphasisEnd} project. This is the project that we use to work on GitLab. At first, a project seems like a simple repository, but at GitLab, a project is so much more.%{lineBreak}%{lineBreak}You can create projects for hosting your codebase, use it as an issue tracker, collaborate on code, and continuously build, test, and deploy your app with built-in GitLab CI/CD.',
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '<br/>',
projectName,
},
false,
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '#js-onboarding-repo-link',
text: sprintf(
s__(
"UserOnboardingTour|Let's take a closer look at the repository of this project. Click on %{emphasisStart}Repository%{emphasisEnd}.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/-/tree/master$`, ''),
getHelpContent: ({ projectName }) => [
{
text: sprintf(
s__(
"UserOnboardingTour|This is the repository for the %{emphasisStart}%{projectName}%{emphasisEnd} project. All our code is stored here. Feel free to explore and take a closer look at folders and files.%{lineBreak}%{lineBreak}Above the file structure you can see the latest commit, who the author is and the status of the CI/CD pipeline.%{lineBreak}%{lineBreak}If you scroll down below the file structure, you'll find the Readme of this project. This is defined in the README.md file at the root of the repository.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '</br>',
projectName,
},
false,
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '#js-onboarding-commits-link',
text: sprintf(
s__(
"UserOnboardingTour|Let's take a closer look at all the commits. Click on %{emphasisStart}Commits%{emphasisEnd}.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/-/commits/master$`, ''),
getHelpContent: () => [
{
text: s__(
'UserOnboardingTour|Commits are shown in chronological order and can be filtered by the commit message or by the branch.',
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '.js-onboarding-commit-item',
text: s__('UserOnboardingTour|Click to open the latest commit to see its details.'),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/commit/[a-z0-9]+$`, ''),
getHelpContent: () => [
{
text: sprintf(
s__(
"UserOnboardingTour|Here you can see what changes were made with this commit, on what branch and if there's a related merge request. The status of the pipeline will also show up if CI/CD is set up.%{lineBreak}%{lineBreak}You can also comment on the lines of code that were changed and start a discussion with your colleagues!",
),
{
lineBreak: '</br>',
},
false,
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '#js-onboarding-branches-link',
text: sprintf(
s__(
"UserOnboardingTour|Alright, that's it for Commits. Let's take a look at the %{emphasisStart}Branches%{emphasisEnd}.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/-/branches$`, ''),
getHelpContent: ({ projectName }) => [
{
text: sprintf(
s__(
"UserOnboardingTour|Here's an overview of branches in the %{emphasisStart}%{projectName}%{emphasisEnd} project. They're split into Active and Stale.%{lineBreak}%{lineBreak}From here, you can create a new merge request from a branch, or compare the branch to any other branch in the project. By default, it will compare it to the master branch.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '</br>',
projectName,
},
false,
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '.js-onboarding-compare-branches',
text: sprintf(
s__(
'UserOnboardingTour|Click on one of the %{emphasisStart}Compare%{emphasisEnd} buttons to compare a branch to master.',
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ projectFullPath }) =>
new RegExp(`${projectFullPath}/compare/master\\.\\.\\..+$`, ''),
getHelpContent: () => [
{
text: s__(
"UserOnboardingTour|Here you can compare the changes of this branch to another one. Changes are divided by files so that it's easier to see what was changed where.",
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '#js-onboarding-issues-link',
text: sprintf(
s__(
"UserOnboardingTour|That's it for the Repository. Let's take a look at the %{emphasisStart}Issues%{emphasisEnd}.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/issues$`, ''),
getHelpContent: ({ projectName }) => [
{
text: sprintf(
s__(
'UserOnboardingTour|Issues are great for communicating and keeping track of progress in GitLab. These are all issues that are open in the %{emphasisStart}%{projectName}%{emphasisEnd}.%{lineBreak}%{lineBreak}You can help us improve GitLab by contributing work to issues that are labeled <span class="badge color-label accept-mr-label">Accepting merge requests</span>.%{lineBreak}%{lineBreak}This list can be filtered by labels, milestones, assignees, authors... We\'ll show you how it looks when the list is filtered by a label.',
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '</br>',
projectName,
},
false,
),
buttons: [
{
text: s__('UserOnboardingTour|Ok, show me'),
btnClass: 'btn-primary',
// eslint-disable-next-line @gitlab/require-i18n-strings
redirectPath: `issues?${LABEL_SEARCH_QUERY}`,
},
],
},
],
actionPopover: null,
},
{
forUrl: ({ projectFullPath }) =>
new RegExp(
`${projectFullPath}/issues\\?scope=all&state=opened&label_name\\[\\]=${encodeURIComponent(
ACCEPTING_MR_LABEL_TEXT,
)}$`,
'',
),
getHelpContent: () => [
{
text: s__(
"UserOnboardingTour|These are all the issues that are available for community contributions. Let's take a closer look at one of them.",
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '.js-onboarding-issue-item',
text: s__('UserOnboardingTour|Open one of the issues by clicking on its title.'),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/issues/[0-9]+$`, ''),
getHelpContent: () => [
{
text: sprintf(
s__(
"UserOnboardingTour|There's a lot of information here but don't worry, we'll go through it.%{lineBreak}%{lineBreak}On the top you can see the status of the issue and when it was opened and by whom. Directly below it is the issue description and below that are other %{emphasisStart}related issues%{emphasisEnd} and %{emphasisStart}merge requests%{emphasisEnd} (if any). Then below that is the %{emphasisStart}discussion%{emphasisEnd}, that's where most of the communication happens.%{lineBreak}%{lineBreak}On the right, there's a sidebar where you can view/change the %{emphasisStart}assignee, milestone, due date, labels, weight%{emphasisEnd}, etc.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '</br>',
},
false,
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '#js-onboarding-mr-link',
text: sprintf(
s__(
"UserOnboardingTour|That's it for issues. Let'st take a look at %{emphasisStart}Merge Requests%{emphasisEnd}.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/-/merge_requests$`, ''),
getHelpContent: () => [
{
text: s__(
'UserOnboardingTour|This is an overview of all merge requests in this project. Similarly to the issues overview it can be filtered down by things like labels, milestones, authors, assignees, etc.',
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '.js-onboarding-mr-item',
text: s__(
"UserOnboardingTour|Let's take a closer look at a merge request. Click on the title of one.",
),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/-/merge_requests/[0-9]+$`, ''),
getHelpContent: () => [
{
text: sprintf(
s__(
"UserOnboardingTour|The structure of this page is very similar to issues. Status, description, discussion and the sidebar are all here.%{lineBreak}%{lineBreak}But take a look below the description and you'll notice that there's more information about the merge request, the CI/CD pipeline and the options for approving it.%{lineBreak}%{lineBreak}Alongside the discussion you can also see more information about commits in this merge request, the status of pipelines and review all changes that were made.",
),
{
lineBreak: '</br>',
},
false,
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '#js-onboarding-pipelines-link',
text: sprintf(
s__(
"UserOnboardingTour|That's it for merge requests. Now for the final part of this guided tour - the %{emphasisStart}CI/CD%{emphasisEnd}.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/pipelines$`, ''),
getHelpContent: ({ projectName }) => [
{
text: sprintf(
s__(
"UserOnboardingTour|These are all the CI/CD pipelines we have for our %{emphasisStart}%{projectName}%{emphasisEnd} project.%{lineBreak}%{lineBreak}Here you can see the status of each pipeline, for what commit it's running for, its stages and the status for them.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '</br>',
projectName,
},
false,
),
buttons: [{ text: s__('UserOnboardingTour|Got it'), btnClass: 'btn-primary' }],
},
],
actionPopover: {
selector: '.js-onboarding-pipeline-item',
text: sprintf(
s__(
'UserOnboardingTour|Click on one of the %{emphasisStart}pipeline IDs%{emphasisEnd} to see the details of a pipeline.',
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/-/pipelines/[0-9]+$`, ''),
getHelpContent: () => [
{
text: sprintf(
s__(
'UserOnboardingTour|Here you can see the breakdown of the pipelines: its stages and jobs in each of the stages and their status.%{lineBreak}%{lineBreak}Our CI/CD pipelines are quite complex, most of our users have fewer and simpler pipelines.',
),
{
lineBreak: '</br>',
},
false,
),
buttons: [
{
text: s__('UserOnboardingTour|Got it'),
btnClass: 'btn-primary',
dismissPopover: false,
},
],
},
{
text: sprintf(
s__(
"UserOnboardingTour|%{emphasisStart}Well done!%{emphasisEnd}%{lineBreak}%{lineBreak}That's it for our guided tour, congratulations for making it all the way to the end!%{lineBreak}%{lineBreak}We hope this gave you a good overview of GitLab and how it can help you. We'll now show you how to create your own project and invite your colleagues.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '</br>',
},
false,
),
buttons: [
{
text: s__("UserOnboardingTour|Ok, let's go"),
btnClass: 'btn-primary',
nextPart: AVAILABLE_TOURS.CREATE_PROJECT_TOUR,
},
{
text: s__('UserOnboardingTour|No thanks'),
btnClass: 'btn-secondary',
showExitTourContent: true,
},
],
},
],
actionPopover: null,
},
];
const CREATE_PROJECT_TOUR = [
{
forUrl: ({ projectFullPath }) => new RegExp(`${projectFullPath}/-/pipelines/[0-9]+$`, ''),
getHelpContent: null,
actionPopover: {
selector: '#js-onboarding-new-project-link',
text: s__(
'UserOnboardingTour|Take a look. Here\'s a nifty menu for quickly creating issues, merge requests, snippets, projects and groups. Click on it and select "New project" from the "GitLab" section to get started.',
),
placement: 'bottom',
},
},
{
forUrl: () => new RegExp(`/projects/new\\?*.*$`, ''),
getHelpContent: () => [
{
text: sprintf(
s__(
"UserOnboardingTour|Here you can create a project from scratch, start with a template or import a repository from other platforms. Whatever you choose, we'll guide you through the process.%{lineBreak}%{lineBreak}Fill in your new project information and click on %{emphasisStart}Create Project%{emphasisEnd} to progress to the next step.",
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '</br>',
},
false,
),
buttons: null,
},
],
actionPopover: null,
},
{
forUrl: ({ createdProjectPath }) => new RegExp(`${createdProjectPath}$`, ''),
getHelpContent: () => [
{
text: sprintf(
s__(
'UserOnboardingTour|Sweet! Your project was created and is ready to be used.%{lineBreak}%{lineBreak}You can start adding files to the repository or clone it. One last thing we want to show you is how to invite your colleagues to your new project.',
),
{
lineBreak: '</br>',
},
false,
),
buttons: [
{
text: s__("UserOnboardingTour|Ok, let's go"),
btnClass: 'btn-primary',
nextPart: AVAILABLE_TOURS.INVITE_COLLEAGUES_TOUR,
},
{
text: s__('UserOnboardingTour|No thanks'),
btnClass: 'btn-secondary',
showExitTourContent: true,
},
],
},
],
actionPopover: null,
},
];
const INVITE_COLLEAGUES_TOUR = [
{
forUrl: ({ createdProjectPath }) => new RegExp(`${createdProjectPath}$`, ''),
getHelpContent: null,
actionPopover: {
selector: '#js-onboarding-settings-link',
text: sprintf(
s__(
'UserOnboardingTour|Adding other members to a project is done through Project Settings. Click on %{emphasisStart}Settings%{emphasisEnd}.',
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ createdProjectPath }) => new RegExp(`${createdProjectPath}/edit$`, ''),
getHelpContent: null,
actionPopover: {
selector: '#js-onboarding-members-link',
text: sprintf(
s__('UserOnboardingTour|Awesome! Now click on %{emphasisStart}Members%{emphasisEnd}.'),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
},
false,
),
},
},
{
forUrl: ({ createdProjectPath }) => new RegExp(`${createdProjectPath}/-/project_members$`, ''),
getHelpContent: () => [
{
text: sprintf(
s__(
'UserOnboardingTour|Here you can see the current members of the project (just you at the moment) and invite new members.%{lineBreak}%{lineBreak}You can invite multiple members at once (existing GitLab users or invite by email) and you can also set their roles and permissions.%{lineBreak}%{lineBreak}Add a few members and click on %{emphasisStart}Add to project%{emphasisEnd} to complete this step.',
),
{
emphasisStart: '<strong>',
emphasisEnd: '</strong>',
lineBreak: '</br>',
},
false,
),
buttons: [
{
text: s__('UserOnboardingTour|Got it'),
btnClass: 'btn-primary',
showExitTourContent: true,
},
],
},
],
actionPopover: null,
},
];
export default {
[AVAILABLE_TOURS.GUIDED_GITLAB_TOUR]: GUIDED_GITLAB_TOUR,
[AVAILABLE_TOURS.CREATE_PROJECT_TOUR]: CREATE_PROJECT_TOUR,
[AVAILABLE_TOURS.INVITE_COLLEAGUES_TOUR]: INVITE_COLLEAGUES_TOUR,
};
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 Vue from 'vue';
import OnboardingHelperApp from 'ee/onboarding/onboarding_helper/components/app.vue';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import eventHub from 'ee/onboarding/onboarding_helper/event_hub';
import createStore from 'ee/onboarding/onboarding_helper/store';
import actionPopoverUtils from 'ee/onboarding/onboarding_helper/action_popover_utils';
import Tracking from '~/tracking';
import { mockTourData } from '../mock_data';
import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
describe('User onboarding helper app', () => {
let vm;
let store;
const initialData = {
url: 'http://gitlab-org/gitlab-test/foo',
projectFullPath: 'http://gitlab-org/gitlab-test',
projectName: 'Mock Project',
tourData: mockTourData,
tourKey: 1,
lastStepIndex: -1,
createdProjectPath: '',
};
const tourTitles = [{ id: 1, title: 'First tour' }, { id: 2, title: 'Second tour' }];
const exitTourContent = {
text: 'feedback content',
feedbackButtons: true,
feedbackSize: 5,
};
const feedbackContent = {
text: 'exit tour content',
buttons: [{ text: 'OK', btnClass: 'btn-primary' }],
};
const dntExitTourContent = {
text: 'dnt exit tour content',
buttonText: 'Got it',
exitTour: true,
};
const defaultProps = {
tourTitles,
exitTourContent,
feedbackContent,
dntExitTourContent,
goldenTanukiSvgPath: 'illustrations/golden_tanuki.svg',
};
const createComponent = ({ props = defaultProps } = {}) => {
const Component = Vue.extend(OnboardingHelperApp);
store = createStore();
store.dispatch('setInitialData', initialData);
return mountComponentWithStore(Component, { props, store });
};
beforeEach(() => {
vm = createComponent();
jest.spyOn(vm, 'init');
vm.$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('helpContentData', () => {
it('returns an object containing the help content data', () => {
const helpContent = mockTourData[initialData.tourKey][0].getHelpContent()[0];
expect(vm.helpContentData).toEqual(helpContent);
});
it('returns null if showStepContent is false', () => {
vm.showStepContent = false;
expect(vm.helpContentData).toBeNull();
});
it('returns an object containing exit tour content if exitTour is true', () => {
store.dispatch('setExitTour', true);
expect(vm.helpContentData).toEqual(exitTourContent);
});
it('returns an object containing tour feedback content if tourFeedback is true', () => {
store.dispatch('setTourFeedback', true);
expect(vm.helpContentData).toEqual(feedbackContent);
});
it('returns an object containing do not track exit content if dntExitTour is true', () => {
store.dispatch('setDntExitTour', true);
expect(vm.helpContentData).toEqual(dntExitTourContent);
});
});
describe('completedSteps', () => {
it('returns 3 if the lastStepIndex is 1', () => {
vm.$store.state.lastStepIndex = 3;
expect(vm.completedSteps).toBe(3);
});
it('returns 0 if the lastStepIndex is -1', () => {
vm.$store.state.lastStepIndex = -1;
expect(vm.completedSteps).toBe(0);
});
});
});
describe('mounted', () => {
it('calls the init method', () => {
expect(vm.init).toHaveBeenCalled();
expect(vm.showStepContent).toBe(true);
});
});
describe('methods', () => {
describe('initActionPopover', () => {
it('calls renderPopover with the correct data', () => {
jest.spyOn(actionPopoverUtils, 'renderPopover');
const expected = {
selector: '.popup-trigger',
text: 'foo',
placement: 'top',
showPopover: false,
};
const { selector, text, placement, showPopover } = expected;
vm.initActionPopover();
expect(actionPopoverUtils.renderPopover).toHaveBeenCalledWith(
selector,
text,
placement,
showPopover,
);
});
it('calls renderPopover with showPopover=true if there is no helpContent data and no popover selector for the current url', () => {
jest.spyOn(actionPopoverUtils, 'renderPopover');
vm.$store.state.url = 'http://gitlab-org/gitlab-test/xyz';
const expected = {
selector: null,
text: 'foo',
placement: 'top',
showPopover: true,
};
const { selector, text, placement, showPopover } = expected;
vm.initActionPopover();
expect(actionPopoverUtils.renderPopover).toHaveBeenCalledWith(
selector,
text,
placement,
showPopover,
);
});
});
describe('showActionPopover', () => {
it('emits the "onboardingHelper.showActionPopover" event', () => {
jest.spyOn(eventHub, '$emit');
vm.showActionPopover();
expect(eventHub.$emit).toHaveBeenCalledWith('onboardingHelper.showActionPopover');
});
});
describe('hideActionPopover', () => {
it('emits the "onboardingHelper.hideActionPopover" event', () => {
jest.spyOn(eventHub, '$emit');
vm.hideActionPopover();
expect(eventHub.$emit).toHaveBeenCalledWith('onboardingHelper.hideActionPopover');
});
});
describe('handleRestartStep', () => {
it('calls the "showExitTourContent" and "handleFeedbackTourContent" methods', () => {
jest.spyOn(vm, 'showExitTourContent');
jest.spyOn(vm, 'handleFeedbackTourContent');
vm.handleRestartStep();
expect(vm.showExitTourContent).toHaveBeenCalledWith(false);
expect(vm.handleFeedbackTourContent).toHaveBeenCalledWith(false);
});
it('emits the "onboardingHelper.hideActionPopover" event', () => {
jest.spyOn(eventHub, '$emit');
vm.handleRestartStep();
expect(eventHub.$emit).toHaveBeenCalledWith('onboardingHelper.hideActionPopover');
});
});
describe('handleSkipStep', () => {
it('calls the click method on given popover selector if there is a stepContent', () => {
const {
actionPopover: { selector },
} = vm.stepContent;
const fakeLink = {
click: () => {},
};
jest.spyOn(document, 'querySelector').mockReturnValue(fakeLink);
jest.spyOn(fakeLink, 'click');
vm.handleSkipStep();
expect(document.querySelector).toHaveBeenCalledWith(`${selector}`);
expect(fakeLink.click).toHaveBeenCalled();
});
});
describe('handleStepContentButton', () => {
it('shows the exitTour content', () => {
jest.spyOn(vm, 'showExitTourContent');
const button = {
showExitTourContent: true,
};
vm.handleStepContentButton(button);
expect(vm.showExitTourContent).toHaveBeenCalledWith(true);
});
it('sets dismissPopover to true when true/undefined on button config', () => {
let button = {
dismissPopover: true,
};
vm.handleStepContentButton(button);
expect(vm.dismissPopover).toBe(true);
button = {};
vm.handleStepContentButton(button);
expect(vm.dismissPopover).toBe(true);
});
it('does not set dismissPopover to true when false on button config', () => {
const button = {
dismissPopover: false,
};
vm.handleStepContentButton(button);
expect(vm.dismissPopover).toBe(false);
});
it('redirects to the redirectPath', () => {
const button = {
redirectPath: 'my-redirect/path',
};
vm.handleStepContentButton(button);
expect(redirectTo).toHaveBeenCalledWith(button.redirectPath);
});
it('switches to the next tour part and calls initActionPopover', () => {
jest.spyOn(vm.$store, 'dispatch');
jest.spyOn(vm, 'initActionPopover');
const nextPart = 2;
const button = {
nextPart,
};
vm.handleStepContentButton(button);
expect(vm.$store.dispatch).toHaveBeenCalledWith('switchTourPart', nextPart);
expect(vm.initActionPopover).toHaveBeenCalled();
});
it('shows the next content item', () => {
jest.spyOn(vm.$store, 'dispatch');
const button = {};
vm.$store.state.url = 'http://gitlab-org/gitlab-test/foo';
vm.$store.state.lastStepIndex = 0;
vm.handleStepContentButton(button);
expect(vm.$store.dispatch).toHaveBeenCalledWith('setHelpContentIndex', 1);
});
});
describe('handleFeedbackButton', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
jest.spyOn(vm.$store, 'dispatch');
});
it('tracks feedback and shows the exit tour content', () => {
vm.handleFeedbackButton({ feedbackResult: 1 });
expect(Tracking.event).toHaveBeenCalledWith('onboarding', 'click_link', {
label: 'feedback',
property: 'feedback_result',
value: 1,
});
expect(vm.$store.dispatch).toHaveBeenCalledWith('setExitTour', true);
});
it('shows the exit tour content but does not track feedback', () => {
vm.handleFeedbackButton({ feedbackResult: null });
expect(Tracking.event).not.toHaveBeenCalledWith();
expect(vm.$store.dispatch).toHaveBeenCalledWith('setExitTour', true);
});
});
describe('showExitTourContent', () => {
it('sets the "dismissPopover" prop to false', () => {
vm.showExitTourContent(true);
expect(vm.dismissPopover).toBeFalsy();
});
it('calls the "setExitTour" method', () => {
jest.spyOn(vm.$store, 'dispatch');
vm.showExitTourContent(true);
expect(vm.$store.dispatch).toHaveBeenCalledWith('setExitTour', true);
});
});
describe('handleFeedbackTourContent', () => {
it('sets the "dismissPopover" prop to false', () => {
vm.handleFeedbackTourContent(true);
expect(vm.dismissPopover).toBeFalsy();
});
it('calls the "setTourFeedback" method', () => {
jest.spyOn(vm.$store, 'dispatch');
vm.handleFeedbackTourContent(true);
expect(vm.$store.dispatch).toHaveBeenCalledWith('setTourFeedback', true);
});
});
describe('handleDntExitTourContent', () => {
it('sets the "dismissPopover" prop to false', () => {
vm.handleDntExitTourContent(true);
expect(vm.dismissPopover).toBeFalsy();
});
it('calls the "setDntExitTour" method', () => {
jest.spyOn(vm.$store, 'dispatch');
vm.handleDntExitTourContent(true);
expect(vm.$store.dispatch).toHaveBeenCalledWith('setDntExitTour', true);
});
});
describe('handleExitTourButton', () => {
it('emits the "onboardingHelper.hideActionPopover" event', () => {
jest.spyOn(eventHub, '$emit');
vm.handleExitTourButton();
expect(eventHub.$emit).toHaveBeenCalledWith('onboardingHelper.hideActionPopover');
});
it('calls the "setDismissed" method with true', () => {
jest.spyOn(vm.$store, 'dispatch');
vm.handleExitTourButton();
expect(vm.$store.dispatch).toHaveBeenCalledWith('setDismissed', true);
});
it('emits the "onboardingHelper.destroyActionPopover" event', () => {
jest.spyOn(eventHub, '$emit');
vm.handleExitTourButton();
expect(eventHub.$emit).toHaveBeenCalledWith('onboardingHelper.destroyActionPopover');
});
});
});
});
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 { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlProgressBar, GlLoadingIcon } from '@gitlab/ui';
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 Tracking from '~/tracking';
const localVue = createLocalVue();
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',
showLoadingIcon: false,
};
function createComponent(propsData) {
wrapper = shallowMount(localVue.extend(component), {
propsData,
localVue,
});
}
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', done => {
wrapper.setProps({ initialShow: true });
wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.vm.showPopover).toBe(true);
})
.then(done)
.catch(done.fail);
});
});
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();
});
it('displays the loading icon instead of the tanuki SVG when the "Skip this step" link is clicked', done => {
wrapper.find('.qa-skip-step-link').vm.$emit('click');
expect(wrapper.vm.showLoadingIcon).toBe(true);
wrapper.vm.$nextTick(() => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find('.avatar img').exists()).toBe(false);
done();
});
});
});
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('beginExitTourProcess', () => {
const findExitTourLink = () => wrapper.find('.qa-exit-tour-link');
it('emits the "showDntExitContent" event when the "Exit Learn GitLab" link is clicked and tracking is not enabled', () => {
jest.spyOn(Tracking, 'enabled').mockReturnValue(false);
findExitTourLink().vm.$emit('click');
expect(wrapper.emitted('showDntExitContent')).toBeTruthy();
expect(Tracking.enabled).toHaveBeenCalled();
});
it('emits the "showFeedbackContent" event when the "Exit Learn GitLab" link is clicked and tracking is enabled', () => {
jest.spyOn(Tracking, 'enabled').mockReturnValue(true);
findExitTourLink().vm.$emit('click');
expect(wrapper.emitted('showFeedbackContent')).toBeTruthy();
});
});
describe('callStepContentButton', () => {
it('emits the "clickStepContentButton" event when a popover button is clicked', () => {
const button = defaultProps.helpContent.buttons[0];
wrapper.vm.callStepContentButton(button);
expect(wrapper.emittedByOrder()).toEqual([
{ name: 'clickStepContentButton', args: [button] },
]);
});
});
describe('callExitTour', () => {
it('emits the "clickExitTourButton" event when close Learn GitLab is clicked', () => {
wrapper.vm.callExitTour();
expect(wrapper.emittedByOrder()).toEqual([{ name: 'clickExitTourButton', args: [] }]);
});
});
describe('submitFeedback', () => {
it('emits the "clickFeedbackButton" event when feedback on tour is clicked', () => {
const button = defaultProps.helpContent.buttons[0];
wrapper.vm.submitFeedback(button);
expect(wrapper.emittedByOrder()).toEqual([{ name: 'clickFeedbackButton', 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', done => {
const btn = wrapper.find('.qa-toggle-btn');
const icon = btn.find(Icon);
expect(icon.props('name')).toEqual('ellipsis_h');
wrapper.vm.expanded = true;
wrapper.vm
.$nextTick()
.then(() => {
expect(icon.props('name')).toEqual('close');
})
.then(done)
.catch(done.fail);
});
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(
expect.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, 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));
});
});
});
...@@ -3424,9 +3424,6 @@ msgstr "" ...@@ -3424,9 +3424,6 @@ msgstr ""
msgid "Badges|e.g. %{exampleUrl}" msgid "Badges|e.g. %{exampleUrl}"
msgstr "" msgstr ""
msgid "Badge|New"
msgstr ""
msgid "Balsamiq file could not be loaded." msgid "Balsamiq file could not be loaded."
msgstr "" msgstr ""
...@@ -3562,9 +3559,6 @@ msgstr "" ...@@ -3562,9 +3559,6 @@ msgstr ""
msgid "Blog" msgid "Blog"
msgstr "" msgstr ""
msgid "Blue helpers indicate an action to be taken."
msgstr ""
msgid "Board name" msgid "Board name"
msgstr "" msgstr ""
...@@ -8078,9 +8072,6 @@ msgstr "" ...@@ -8078,9 +8072,6 @@ msgstr ""
msgid "Don't show again" msgid "Don't show again"
msgstr "" msgstr ""
msgid "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>."
msgstr ""
msgid "Done" msgid "Done"
msgstr "" msgstr ""
...@@ -11039,9 +11030,6 @@ msgstr "" ...@@ -11039,9 +11030,6 @@ msgstr ""
msgid "Go to your snippets" msgid "Go to your snippets"
msgstr "" msgstr ""
msgid "Golden Tanuki"
msgstr ""
msgid "Google Cloud Platform" msgid "Google Cloud Platform"
msgstr "" msgstr ""
...@@ -15302,9 +15290,6 @@ msgstr "" ...@@ -15302,9 +15290,6 @@ msgstr ""
msgid "No, directly import the existing email addresses and usernames." msgid "No, directly import the existing email addresses and usernames."
msgstr "" msgstr ""
msgid "No, not interested right now"
msgstr ""
msgid "No. of commits" msgid "No. of commits"
msgstr "" msgstr ""
...@@ -15350,9 +15335,6 @@ msgstr "" ...@@ -15350,9 +15335,6 @@ msgstr ""
msgid "Not found." msgid "Not found."
msgstr "" msgstr ""
msgid "Not helpful"
msgstr ""
msgid "Not now" msgid "Not now"
msgstr "" msgstr ""
...@@ -15578,9 +15560,6 @@ msgstr "" ...@@ -15578,9 +15560,6 @@ msgstr ""
msgid "Oh no!" msgid "Oh no!"
msgstr "" msgstr ""
msgid "Ok let's go"
msgstr ""
msgid "Oldest first" msgid "Oldest first"
msgstr "" msgstr ""
...@@ -15638,9 +15617,6 @@ msgstr "" ...@@ -15638,9 +15617,6 @@ msgstr ""
msgid "OnDemandScans|Target URL" msgid "OnDemandScans|Target URL"
msgstr "" msgstr ""
msgid "Onboarding"
msgstr ""
msgid "Once imported, repositories can be mirrored over SSH. Read more %{link_start}here%{link_end}." msgid "Once imported, repositories can be mirrored over SSH. Read more %{link_start}here%{link_end}."
msgstr "" msgstr ""
...@@ -21019,9 +20995,6 @@ msgstr "" ...@@ -21019,9 +20995,6 @@ msgstr ""
msgid "Skip outdated deployment jobs" msgid "Skip outdated deployment jobs"
msgstr "" msgstr ""
msgid "Skip this for now"
msgstr ""
msgid "Skipped" msgid "Skipped"
msgstr "" msgstr ""
...@@ -24942,150 +24915,6 @@ msgstr "" ...@@ -24942,150 +24915,6 @@ msgstr ""
msgid "UserList|created %{timeago}" msgid "UserList|created %{timeago}"
msgstr "" msgstr ""
msgid "UserOnboardingTour|%{activeTour}/%{totalTours}"
msgstr ""
msgid "UserOnboardingTour|%{completed}/%{total} steps completed"
msgstr ""
msgid "UserOnboardingTour|%{emphasisStart}Well done!%{emphasisEnd}%{lineBreak}%{lineBreak}That's it for our guided tour, congratulations for making it all the way to the end!%{lineBreak}%{lineBreak}We hope this gave you a good overview of GitLab and how it can help you. We'll now show you how to create your own project and invite your colleagues."
msgstr ""
msgid "UserOnboardingTour|Adding other members to a project is done through Project Settings. Click on %{emphasisStart}Settings%{emphasisEnd}."
msgstr ""
msgid "UserOnboardingTour|Alright, that's it for Commits. Let's take a look at the %{emphasisStart}Branches%{emphasisEnd}."
msgstr ""
msgid "UserOnboardingTour|Awesome! Now click on %{emphasisStart}Members%{emphasisEnd}."
msgstr ""
msgid "UserOnboardingTour|Click on one of the %{emphasisStart}Compare%{emphasisEnd} buttons to compare a branch to master."
msgstr ""
msgid "UserOnboardingTour|Click on one of the %{emphasisStart}pipeline IDs%{emphasisEnd} to see the details of a pipeline."
msgstr ""
msgid "UserOnboardingTour|Click to open the latest commit to see its details."
msgstr ""
msgid "UserOnboardingTour|Close 'Learn GitLab'"
msgstr ""
msgid "UserOnboardingTour|Commits are shown in chronological order and can be filtered by the commit message or by the branch."
msgstr ""
msgid "UserOnboardingTour|Create a project"
msgstr ""
msgid "UserOnboardingTour|Exit 'Learn GitLab'"
msgstr ""
msgid "UserOnboardingTour|Got it"
msgstr ""
msgid "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}"
msgstr ""
msgid "UserOnboardingTour|Guided GitLab Tour"
msgstr ""
msgid "UserOnboardingTour|Here you can compare the changes of this branch to another one. Changes are divided by files so that it's easier to see what was changed where."
msgstr ""
msgid "UserOnboardingTour|Here you can create a project from scratch, start with a template or import a repository from other platforms. Whatever you choose, we'll guide you through the process.%{lineBreak}%{lineBreak}Fill in your new project information and click on %{emphasisStart}Create Project%{emphasisEnd} to progress to the next step."
msgstr ""
msgid "UserOnboardingTour|Here you can see the breakdown of the pipelines: its stages and jobs in each of the stages and their status.%{lineBreak}%{lineBreak}Our CI/CD pipelines are quite complex, most of our users have fewer and simpler pipelines."
msgstr ""
msgid "UserOnboardingTour|Here you can see the current members of the project (just you at the moment) and invite new members.%{lineBreak}%{lineBreak}You can invite multiple members at once (existing GitLab users or invite by email) and you can also set their roles and permissions.%{lineBreak}%{lineBreak}Add a few members and click on %{emphasisStart}Add to project%{emphasisEnd} to complete this step."
msgstr ""
msgid "UserOnboardingTour|Here you can see what changes were made with this commit, on what branch and if there's a related merge request. The status of the pipeline will also show up if CI/CD is set up.%{lineBreak}%{lineBreak}You can also comment on the lines of code that were changed and start a discussion with your colleagues!"
msgstr ""
msgid "UserOnboardingTour|Here's an overview of branches in the %{emphasisStart}%{projectName}%{emphasisEnd} project. They're split into Active and Stale.%{lineBreak}%{lineBreak}From here, you can create a new merge request from a branch, or compare the branch to any other branch in the project. By default, it will compare it to the master branch."
msgstr ""
msgid "UserOnboardingTour|Invite colleagues"
msgstr ""
msgid "UserOnboardingTour|Issues are great for communicating and keeping track of progress in GitLab. These are all issues that are open in the %{emphasisStart}%{projectName}%{emphasisEnd}.%{lineBreak}%{lineBreak}You can help us improve GitLab by contributing work to issues that are labeled <span class=\"badge color-label accept-mr-label\">Accepting merge requests</span>.%{lineBreak}%{lineBreak}This list can be filtered by labels, milestones, assignees, authors... We'll show you how it looks when the list is filtered by a label."
msgstr ""
msgid "UserOnboardingTour|Learn GitLab"
msgstr ""
msgid "UserOnboardingTour|Let's take a closer look at a merge request. Click on the title of one."
msgstr ""
msgid "UserOnboardingTour|Let's take a closer look at all the commits. Click on %{emphasisStart}Commits%{emphasisEnd}."
msgstr ""
msgid "UserOnboardingTour|Let's take a closer look at the repository of this project. Click on %{emphasisStart}Repository%{emphasisEnd}."
msgstr ""
msgid "UserOnboardingTour|No thanks"
msgstr ""
msgid "UserOnboardingTour|Ok, let's go"
msgstr ""
msgid "UserOnboardingTour|Ok, show me"
msgstr ""
msgid "UserOnboardingTour|Open one of the issues by clicking on its title."
msgstr ""
msgid "UserOnboardingTour|Restart this step"
msgstr ""
msgid "UserOnboardingTour|Skip this step"
msgstr ""
msgid "UserOnboardingTour|Sweet! Your project was created and is ready to be used.%{lineBreak}%{lineBreak}You can start adding files to the repository or clone it. One last thing we want to show you is how to invite your colleagues to your new project."
msgstr ""
msgid "UserOnboardingTour|Take a look. Here's a nifty menu for quickly creating issues, merge requests, snippets, projects and groups. Click on it and select \"New project\" from the \"GitLab\" section to get started."
msgstr ""
msgid "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."
msgstr ""
msgid "UserOnboardingTour|Thanks for the feedback! %{thumbsUp}"
msgstr ""
msgid "UserOnboardingTour|That's it for issues. Let'st take a look at %{emphasisStart}Merge Requests%{emphasisEnd}."
msgstr ""
msgid "UserOnboardingTour|That's it for merge requests. Now for the final part of this guided tour - the %{emphasisStart}CI/CD%{emphasisEnd}."
msgstr ""
msgid "UserOnboardingTour|That's it for the Repository. Let's take a look at the %{emphasisStart}Issues%{emphasisEnd}."
msgstr ""
msgid "UserOnboardingTour|The structure of this page is very similar to issues. Status, description, discussion and the sidebar are all here.%{lineBreak}%{lineBreak}But take a look below the description and you'll notice that there's more information about the merge request, the CI/CD pipeline and the options for approving it.%{lineBreak}%{lineBreak}Alongside the discussion you can also see more information about commits in this merge request, the status of pipelines and review all changes that were made."
msgstr ""
msgid "UserOnboardingTour|There's a lot of information here but don't worry, we'll go through it.%{lineBreak}%{lineBreak}On the top you can see the status of the issue and when it was opened and by whom. Directly below it is the issue description and below that are other %{emphasisStart}related issues%{emphasisEnd} and %{emphasisStart}merge requests%{emphasisEnd} (if any). Then below that is the %{emphasisStart}discussion%{emphasisEnd}, that's where most of the communication happens.%{lineBreak}%{lineBreak}On the right, there's a sidebar where you can view/change the %{emphasisStart}assignee, milestone, due date, labels, weight%{emphasisEnd}, etc."
msgstr ""
msgid "UserOnboardingTour|These are all the CI/CD pipelines we have for our %{emphasisStart}%{projectName}%{emphasisEnd} project.%{lineBreak}%{lineBreak}Here you can see the status of each pipeline, for what commit it's running for, its stages and the status for them."
msgstr ""
msgid "UserOnboardingTour|These are all the issues that are available for community contributions. Let's take a closer look at one of them."
msgstr ""
msgid "UserOnboardingTour|This is an overview of all merge requests in this project. Similarly to the issues overview it can be filtered down by things like labels, milestones, authors, assignees, etc."
msgstr ""
msgid "UserOnboardingTour|This is the repository for the %{emphasisStart}%{projectName}%{emphasisEnd} project. All our code is stored here. Feel free to explore and take a closer look at folders and files.%{lineBreak}%{lineBreak}Above the file structure you can see the latest commit, who the author is and the status of the CI/CD pipeline.%{lineBreak}%{lineBreak}If you scroll down below the file structure, you'll find the Readme of this project. This is defined in the README.md file at the root of the repository."
msgstr ""
msgid "UserOnboardingTour|Welcome to the project overview of the %{emphasisStart}%{projectName}%{emphasisEnd} project. This is the project that we use to work on GitLab. At first, a project seems like a simple repository, but at GitLab, a project is so much more.%{lineBreak}%{lineBreak}You can create projects for hosting your codebase, use it as an issue tracker, collaborate on code, and continuously build, test, and deploy your app with built-in GitLab CI/CD."
msgstr ""
msgid "UserProfile|Activity" msgid "UserProfile|Activity"
msgstr "" msgstr ""
...@@ -25323,9 +25152,6 @@ msgstr "" ...@@ -25323,9 +25152,6 @@ msgstr ""
msgid "Versions" msgid "Versions"
msgstr "" msgstr ""
msgid "Very helpful"
msgstr ""
msgid "View Documentation" msgid "View Documentation"
msgstr "" msgstr ""
...@@ -25693,9 +25519,6 @@ msgstr "" ...@@ -25693,9 +25519,6 @@ msgstr ""
msgid "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating." msgid "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating."
msgstr "" msgstr ""
msgid "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."
msgstr ""
msgid "We detected potential spam in the %{humanized_resource_name}. Please solve the reCAPTCHA to proceed." msgid "We detected potential spam in the %{humanized_resource_name}. Please solve the reCAPTCHA to proceed."
msgstr "" msgstr ""
...@@ -25792,9 +25615,6 @@ msgstr "" ...@@ -25792,9 +25615,6 @@ msgstr ""
msgid "Welcome to GitLab.com<br>@%{name}!" msgid "Welcome to GitLab.com<br>@%{name}!"
msgstr "" msgstr ""
msgid "Welcome to the Guided GitLab Tour"
msgstr ""
msgid "Welcome to the guided GitLab tour" msgid "Welcome to the guided GitLab tour"
msgstr "" msgstr ""
...@@ -25842,9 +25662,6 @@ msgstr "" ...@@ -25842,9 +25662,6 @@ msgstr ""
msgid "While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you please double check your settings to make sure you've set up your dashboard correctly." msgid "While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you please double check your settings to make sure you've set up your dashboard correctly."
msgstr "" msgstr ""
msgid "White helpers give contextual information."
msgstr ""
msgid "Who can be an approver?" msgid "Who can be an approver?"
msgstr "" msgstr ""
......
...@@ -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