summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2017-09-04 12:14:00 +0000
committerPhil Hughes <me@iamphill.com>2017-09-04 12:14:00 +0000
commit794a2630fdfa335d90bff0e7e2f37a4b13145ee7 (patch)
tree605f952d87202af8be70dcb0bc88d2eb477aab56
parent970af9964ea9404942818d8c3394d2903955ed69 (diff)
parente7bc0d7c2064ec53805c17cbd150b1e348a865be (diff)
downloadgitlab-ce-794a2630fdfa335d90bff0e7e2f37a4b13145ee7.tar.gz
Merge branch 'feature-highlight' into 'master'
Add feature highlight to Issue Boards in new navigation sidebar Closes #36760 See merge request !13515
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight.js61
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js57
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_options.js12
-rw-r--r--app/assets/javascripts/main.js1
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/buttons.scss17
-rw-r--r--app/assets/stylesheets/framework/feature_highlight.scss94
-rw-r--r--app/views/feature_highlight/_issue_boards.svg98
-rw-r--r--app/views/layouts/nav/_new_project_sidebar.html.haml14
-rw-r--r--app/views/shared/icons/_thumbs_up.svg1
-rw-r--r--spec/javascripts/feature_highlight/feature_highlight_helper_spec.js219
-rw-r--r--spec/javascripts/feature_highlight/feature_highlight_options_spec.js45
-rw-r--r--spec/javascripts/feature_highlight/feature_highlight_spec.js122
13 files changed, 735 insertions, 7 deletions
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js
new file mode 100644
index 00000000000..800ca05cd11
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight.js
@@ -0,0 +1,61 @@
+import Cookies from 'js-cookie';
+import _ from 'underscore';
+import {
+ getCookieName,
+ getSelector,
+ hidePopover,
+ setupDismissButton,
+ mouseenter,
+ mouseleave,
+} from './feature_highlight_helper';
+
+export const setupFeatureHighlightPopover = (id, debounceTimeout = 300) => {
+ const $selector = $(getSelector(id));
+ const $parent = $selector.parent();
+ const $popoverContent = $parent.siblings('.feature-highlight-popover-content');
+ const hideOnScroll = hidePopover.bind($selector);
+ const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout);
+
+ $selector
+ // Setup popover
+ .data('content', $popoverContent.prop('outerHTML'))
+ .popover({
+ html: true,
+ // Override the existing template to add custom CSS classes
+ template: `
+ <div class="popover feature-highlight-popover" role="tooltip">
+ <div class="arrow"></div>
+ <div class="popover-content"></div>
+ </div>
+ `,
+ })
+ .on('mouseenter', mouseenter)
+ .on('mouseleave', debouncedMouseleave)
+ .on('inserted.bs.popover', setupDismissButton)
+ .on('show.bs.popover', () => {
+ window.addEventListener('scroll', hideOnScroll);
+ })
+ .on('hide.bs.popover', () => {
+ window.removeEventListener('scroll', hideOnScroll);
+ })
+ // Display feature highlight
+ .removeAttr('disabled');
+};
+
+export const shouldHighlightFeature = (id) => {
+ const element = document.querySelector(getSelector(id));
+ const previouslyDismissed = Cookies.get(getCookieName(id)) === 'true';
+
+ return element && !previouslyDismissed;
+};
+
+export const highlightFeatures = (highlightOrder) => {
+ const featureId = highlightOrder.find(shouldHighlightFeature);
+
+ if (featureId) {
+ setupFeatureHighlightPopover(featureId);
+ return true;
+ }
+
+ return false;
+};
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
new file mode 100644
index 00000000000..9f741355cd7
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -0,0 +1,57 @@
+import Cookies from 'js-cookie';
+
+export const getCookieName = cookieId => `feature-highlighted-${cookieId}`;
+export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`;
+
+export const showPopover = function showPopover() {
+ if (this.hasClass('js-popover-show')) {
+ return false;
+ }
+ this.popover('show');
+ this.addClass('disable-animation js-popover-show');
+
+ return true;
+};
+
+export const hidePopover = function hidePopover() {
+ if (!this.hasClass('js-popover-show')) {
+ return false;
+ }
+ this.popover('hide');
+ this.removeClass('disable-animation js-popover-show');
+
+ return true;
+};
+
+export const dismiss = function dismiss(cookieId) {
+ Cookies.set(getCookieName(cookieId), true);
+ hidePopover.call(this);
+ this.hide();
+};
+
+export const mouseleave = function mouseleave() {
+ if (!$('.popover:hover').length > 0) {
+ const $featureHighlight = $(this);
+ hidePopover.call($featureHighlight);
+ }
+};
+
+export const mouseenter = function mouseenter() {
+ const $featureHighlight = $(this);
+
+ const showedPopover = showPopover.call($featureHighlight);
+ if (showedPopover) {
+ $('.popover')
+ .on('mouseleave', mouseleave.bind($featureHighlight));
+ }
+};
+
+export const setupDismissButton = function setupDismissButton() {
+ const popoverId = this.getAttribute('aria-describedby');
+ const cookieId = this.dataset.highlight;
+ const $popover = $(this);
+ const dismissWrapper = dismiss.bind($popover, cookieId);
+
+ $(`#${popoverId} .dismiss-feature-highlight`)
+ .on('click', dismissWrapper);
+};
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
new file mode 100644
index 00000000000..fd48f2e87cc
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
@@ -0,0 +1,12 @@
+import { highlightFeatures } from './feature_highlight';
+import bp from '../breakpoints';
+
+const highlightOrder = ['issue-boards'];
+
+export default function domContentLoaded(order) {
+ if (bp.getBreakpointSize() === 'lg') {
+ highlightFeatures(order);
+ }
+}
+
+document.addEventListener('DOMContentLoaded', domContentLoaded.bind(this, highlightOrder));
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 6d7c7e3c930..69a6e131b59 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -102,6 +102,7 @@ import './label_manager';
import './labels';
import './labels_select';
import './layout_nav';
+import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader';
import './line_highlighter';
import './logo';
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index b2b3297e880..c0524bf6aa3 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -51,3 +51,4 @@
@import "framework/snippets";
@import "framework/memory_graph";
@import "framework/responsive-tables";
+@import "framework/feature_highlight";
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index b4a6b214e98..82350c36df0 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -46,6 +46,15 @@
}
}
+@mixin btn-svg {
+ svg {
+ height: 15px;
+ width: 15px;
+ position: relative;
+ top: 2px;
+ }
+}
+
@mixin btn-color($light, $border-light, $normal, $border-normal, $dark, $border-dark, $color) {
background-color: $light;
border-color: $border-light;
@@ -123,6 +132,7 @@
.btn {
@include btn-default;
@include btn-white;
+ @include btn-svg;
color: $gl-text-color;
@@ -222,13 +232,6 @@
}
}
- svg {
- height: 15px;
- width: 15px;
- position: relative;
- top: 2px;
- }
-
svg,
.fa {
&:not(:last-child) {
diff --git a/app/assets/stylesheets/framework/feature_highlight.scss b/app/assets/stylesheets/framework/feature_highlight.scss
new file mode 100644
index 00000000000..ebae473df50
--- /dev/null
+++ b/app/assets/stylesheets/framework/feature_highlight.scss
@@ -0,0 +1,94 @@
+.feature-highlight {
+ position: relative;
+ margin-left: $gl-padding;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ width: 8px;
+ height: 8px;
+ background-color: $blue-500;
+ border-radius: 50%;
+ box-shadow: 0 0 0 rgba($blue-500, 0.4);
+ animation: pulse-highlight 2s infinite;
+ }
+
+ &:hover::before,
+ &.disable-animation::before {
+ animation: none;
+ }
+
+ &[disabled]::before {
+ display: none;
+ }
+}
+
+.is-showing-fly-out {
+ .feature-highlight {
+ display: none;
+ }
+}
+
+.feature-highlight-popover-content {
+ display: none;
+
+ hr {
+ margin: $gl-padding * 0.5 0;
+ }
+
+ .btn-link {
+ @include btn-svg;
+
+ svg path {
+ fill: currentColor;
+ }
+ }
+
+ .dismiss-feature-highlight {
+ padding: 0;
+ }
+
+ svg:first-child {
+ width: 100%;
+ background-color: $indigo-50;
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ border-bottom: 1px solid darken($gray-normal, 8%);
+ }
+}
+
+.popover .feature-highlight-popover-content {
+ display: block;
+}
+
+.feature-highlight-popover {
+ padding: 0;
+
+ .popover-content {
+ padding: 0;
+ }
+}
+
+.feature-highlight-popover-sub-content {
+ padding: 9px 14px;
+}
+
+@include keyframes(pulse-highlight) {
+ 0% {
+ box-shadow: 0 0 0 0 rgba($blue-200, 0.4);
+ }
+
+ 70% {
+ box-shadow: 0 0 0 10px transparent;
+ }
+
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+}
diff --git a/app/views/feature_highlight/_issue_boards.svg b/app/views/feature_highlight/_issue_boards.svg
new file mode 100644
index 00000000000..1522c9d51c9
--- /dev/null
+++ b/app/views/feature_highlight/_issue_boards.svg
@@ -0,0 +1,98 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="214" height="102" viewBox="0 0 214 102" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <defs>
+ <path id="b" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,27 C48,28.1045695 47.1045695,29 46,29 L2,29 C0.8954305,29 1.3527075e-16,28.1045695 0,27 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/>
+ <filter id="a" width="102.1%" height="106.9%" x="-1%" y="-1.7%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/>
+ </filter>
+ <path id="d" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/>
+ <filter id="c" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/>
+ </filter>
+ <path id="e" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/>
+ <path id="h" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/>
+ <filter id="g" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/>
+ </filter>
+ <path id="j" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/>
+ <filter id="i" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/>
+ </filter>
+ <path id="l" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/>
+ <filter id="k" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/>
+ </filter>
+ <path id="n" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/>
+ <filter id="m" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/>
+ </filter>
+ <path id="p" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/>
+ <filter id="o" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox">
+ <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/>
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/>
+ </filter>
+ </defs>
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#D6D4DE" d="M14,21 L62,21 C64.7614237,21 67,23.2385763 67,26 L67,112 C67,114.761424 64.7614237,117 62,117 L14,117 C11.2385763,117 9,114.761424 9,112 L9,26 C9,23.2385763 11.2385763,21 14,21 Z"/>
+ <g transform="translate(11 23)">
+ <path fill="#FFFFFF" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/>
+ <path fill="#FC6D26" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z"/>
+ <g transform="translate(5 10)">
+ <use fill="black" filter="url(#a)" xlink:href="#b"/>
+ <use fill="#F9F9F9" xlink:href="#b"/>
+ </g>
+ <g transform="translate(5 42)">
+ <use fill="black" filter="url(#c)" xlink:href="#d"/>
+ <use fill="#FEF0E8" xlink:href="#d"/>
+ <path fill="#FEE1D3" d="M9,8 L33,8 C34.1045695,8 35,8.8954305 35,10 C35,11.1045695 34.1045695,12 33,12 L9,12 C7.8954305,12 7,11.1045695 7,10 C7,8.8954305 7.8954305,8 9,8 Z"/>
+ <path fill="#FDC4A8" d="M9,17 L17,17 C18.1045695,17 19,17.8954305 19,19 C19,20.1045695 18.1045695,21 17,21 L9,21 C7.8954305,21 7,20.1045695 7,19 C7,17.8954305 7.8954305,17 9,17 Z"/>
+ <path fill="#FC6D26" d="M24,17 L32,17 C33.1045695,17 34,17.8954305 34,19 C34,20.1045695 33.1045695,21 32,21 L24,21 C22.8954305,21 22,20.1045695 22,19 C22,17.8954305 22.8954305,17 24,17 Z"/>
+ </g>
+ </g>
+ <path fill="#D6D4DE" d="M148,26 L196,26 C198.761424,26 201,28.2385763 201,31 L201,117 C201,119.761424 198.761424,122 196,122 L148,122 C145.238576,122 143,119.761424 143,117 L143,31 C143,28.2385763 145.238576,26 148,26 Z"/>
+ <g transform="translate(145 28)">
+ <mask id="f" fill="white">
+ <use xlink:href="#e"/>
+ </mask>
+ <use fill="#FFFFFF" xlink:href="#e"/>
+ <path fill="#FC6D26" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z" mask="url(#f)"/>
+ <g transform="translate(5 10)">
+ <use fill="black" filter="url(#g)" xlink:href="#h"/>
+ <use fill="#F9F9F9" xlink:href="#h"/>
+ </g>
+ <g transform="translate(5 42)">
+ <use fill="black" filter="url(#i)" xlink:href="#j"/>
+ <use fill="#FEF0E8" xlink:href="#j"/>
+ <path fill="#FEE1D3" d="M9 8L33 8C34.1045695 8 35 8.8954305 35 10 35 11.1045695 34.1045695 12 33 12L9 12C7.8954305 12 7 11.1045695 7 10 7 8.8954305 7.8954305 8 9 8zM9 17L13 17C14.1045695 17 15 17.8954305 15 19 15 20.1045695 14.1045695 21 13 21L9 21C7.8954305 21 7 20.1045695 7 19 7 17.8954305 7.8954305 17 9 17z"/>
+ <path fill="#FC6D26" d="M20,17 L24,17 C25.1045695,17 26,17.8954305 26,19 C26,20.1045695 25.1045695,21 24,21 L20,21 C18.8954305,21 18,20.1045695 18,19 C18,17.8954305 18.8954305,17 20,17 Z"/>
+ <path fill="#FDC4A8" d="M31,17 L35,17 C36.1045695,17 37,17.8954305 37,19 C37,20.1045695 36.1045695,21 35,21 L31,21 C29.8954305,21 29,20.1045695 29,19 C29,17.8954305 29.8954305,17 31,17 Z"/>
+ </g>
+ </g>
+ <path fill="#D6D4DE" d="M81,14 L129,14 C131.761424,14 134,16.2385763 134,19 L134,105 C134,107.761424 131.761424,110 129,110 L81,110 C78.2385763,110 76,107.761424 76,105 L76,19 C76,16.2385763 78.2385763,14 81,14 Z"/>
+ <g transform="translate(78 16)">
+ <path fill="#FFFFFF" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/>
+ <g transform="translate(5 10)">
+ <use fill="black" filter="url(#k)" xlink:href="#l"/>
+ <use fill="#EFEDF8" xlink:href="#l"/>
+ <path fill="#E1DBF1" d="M9,8 L33,8 C34.1045695,8 35,8.8954305 35,10 C35,11.1045695 34.1045695,12 33,12 L9,12 C7.8954305,12 7,11.1045695 7,10 C7,8.8954305 7.8954305,8 9,8 Z"/>
+ <path fill="#6B4FBB" d="M9,17 L13,17 C14.1045695,17 15,17.8954305 15,19 C15,20.1045695 14.1045695,21 13,21 L9,21 C7.8954305,21 7,20.1045695 7,19 C7,17.8954305 7.8954305,17 9,17 Z"/>
+ <path fill="#C3B8E3" d="M20,17 L28,17 C29.1045695,17 30,17.8954305 30,19 C30,20.1045695 29.1045695,21 28,21 L20,21 C18.8954305,21 18,20.1045695 18,19 C18,17.8954305 18.8954305,17 20,17 Z"/>
+ </g>
+ <g transform="translate(5 42)">
+ <use fill="black" filter="url(#m)" xlink:href="#n"/>
+ <use fill="#F9F9F9" xlink:href="#n"/>
+ </g>
+ <g transform="translate(5 74)">
+ <rect width="34" height="4" x="7" y="7" fill="#E1DBF1" rx="2"/>
+ <use fill="black" filter="url(#o)" xlink:href="#p"/>
+ <use fill="#F9F9F9" xlink:href="#p"/>
+ </g>
+ <path fill="#6B4FBB" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml
index f5361c7af0c..760c4c97c33 100644
--- a/app/views/layouts/nav/_new_project_sidebar.html.haml
+++ b/app/views/layouts/nav/_new_project_sidebar.html.haml
@@ -99,6 +99,20 @@
= link_to project_boards_path(@project), title: 'Board' do
%span
Board
+ .feature-highlight.js-feature-highlight{ disabled: true, data: { trigger: 'manual', container: 'body', toggle: 'popover', placement: 'right', highlight: 'issue-boards' } }
+ .feature-highlight-popover-content
+ = render 'feature_highlight/issue_boards.svg'
+ .feature-highlight-popover-sub-content
+ %span= _('Use')
+ = link_to 'Issue Boards', project_boards_path(@project)
+ %span= _('to create customized software development workflows like')
+ %strong= _('Scrum')
+ %span= _('or')
+ %strong= _('Kanban')
+ %hr
+ %button.btn-link.dismiss-feature-highlight{ type: 'button' }
+ %span= _("Got it! Don't show this again")
+ = custom_icon('thumbs_up')
= nav_link(controller: :labels) do
= link_to project_labels_path(@project), title: 'Labels' do
diff --git a/app/views/shared/icons/_thumbs_up.svg b/app/views/shared/icons/_thumbs_up.svg
new file mode 100644
index 00000000000..7267462418e
--- /dev/null
+++ b/app/views/shared/icons/_thumbs_up.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.33 5h5.282a2 2 0 0 1 1.963 2.38l-.563 2.905a3 3 0 0 1-.243.732l-1.104 2.286A3 3 0 0 1 10.964 15H7a3 3 0 0 1-3-3V5.7a2 2 0 0 1 .436-1.247l3.11-3.9A.632.632 0 0 1 8.486.5l.138.137a1 1 0 0 1 .28.87L8.33 5zM1 6h2v7H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></svg>
diff --git a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js
new file mode 100644
index 00000000000..114d282e48a
--- /dev/null
+++ b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js
@@ -0,0 +1,219 @@
+import Cookies from 'js-cookie';
+import {
+ getCookieName,
+ getSelector,
+ showPopover,
+ hidePopover,
+ dismiss,
+ mouseleave,
+ mouseenter,
+ setupDismissButton,
+} from '~/feature_highlight/feature_highlight_helper';
+
+describe('feature highlight helper', () => {
+ describe('getCookieName', () => {
+ it('returns `feature-highlighted-` prefix', () => {
+ const cookieId = 'cookieId';
+ expect(getCookieName(cookieId)).toEqual(`feature-highlighted-${cookieId}`);
+ });
+ });
+
+ describe('getSelector', () => {
+ it('returns js-feature-highlight selector', () => {
+ const highlightId = 'highlightId';
+ expect(getSelector(highlightId)).toEqual(`.js-feature-highlight[data-highlight=${highlightId}]`);
+ });
+ });
+
+ describe('showPopover', () => {
+ it('returns true when popover is shown', () => {
+ const context = {
+ hasClass: () => false,
+ popover: () => {},
+ addClass: () => {},
+ };
+
+ expect(showPopover.call(context)).toEqual(true);
+ });
+
+ it('returns false when popover is already shown', () => {
+ const context = {
+ hasClass: () => true,
+ };
+
+ expect(showPopover.call(context)).toEqual(false);
+ });
+
+ it('shows popover', (done) => {
+ const context = {
+ hasClass: () => false,
+ popover: () => {},
+ addClass: () => {},
+ };
+
+ spyOn(context, 'popover').and.callFake((method) => {
+ expect(method).toEqual('show');
+ done();
+ });
+
+ showPopover.call(context);
+ });
+
+ it('adds disable-animation and js-popover-show class', (done) => {
+ const context = {
+ hasClass: () => false,
+ popover: () => {},
+ addClass: () => {},
+ };
+
+ spyOn(context, 'addClass').and.callFake((classNames) => {
+ expect(classNames).toEqual('disable-animation js-popover-show');
+ done();
+ });
+
+ showPopover.call(context);
+ });
+ });
+
+ describe('hidePopover', () => {
+ it('returns true when popover is hidden', () => {
+ const context = {
+ hasClass: () => true,
+ popover: () => {},
+ removeClass: () => {},
+ };
+
+ expect(hidePopover.call(context)).toEqual(true);
+ });
+
+ it('returns false when popover is already hidden', () => {
+ const context = {
+ hasClass: () => false,
+ };
+
+ expect(hidePopover.call(context)).toEqual(false);
+ });
+
+ it('hides popover', (done) => {
+ const context = {
+ hasClass: () => true,
+ popover: () => {},
+ removeClass: () => {},
+ };
+
+ spyOn(context, 'popover').and.callFake((method) => {
+ expect(method).toEqual('hide');
+ done();
+ });
+
+ hidePopover.call(context);
+ });
+
+ it('removes disable-animation and js-popover-show class', (done) => {
+ const context = {
+ hasClass: () => true,
+ popover: () => {},
+ removeClass: () => {},
+ };
+
+ spyOn(context, 'removeClass').and.callFake((classNames) => {
+ expect(classNames).toEqual('disable-animation js-popover-show');
+ done();
+ });
+
+ hidePopover.call(context);
+ });
+ });
+
+ describe('dismiss', () => {
+ const context = {
+ hide: () => {},
+ };
+
+ beforeEach(() => {
+ spyOn(Cookies, 'set').and.callFake(() => {});
+ spyOn(hidePopover, 'call').and.callFake(() => {});
+ spyOn(context, 'hide').and.callFake(() => {});
+ dismiss.call(context);
+ });
+
+ it('sets cookie to true', () => {
+ expect(Cookies.set).toHaveBeenCalled();
+ });
+
+ it('calls hide popover', () => {
+ expect(hidePopover.call).toHaveBeenCalled();
+ });
+
+ it('calls hide', () => {
+ expect(context.hide).toHaveBeenCalled();
+ });
+ });
+
+ describe('mouseleave', () => {
+ it('calls hide popover if .popover:hover is false', () => {
+ const fakeJquery = {
+ length: 0,
+ };
+
+ spyOn($.fn, 'init').and.callFake(selector => (selector === '.popover:hover' ? fakeJquery : $.fn));
+ spyOn(hidePopover, 'call');
+ mouseleave();
+ expect(hidePopover.call).toHaveBeenCalled();
+ });
+
+ it('does not call hide popover if .popover:hover is true', () => {
+ const fakeJquery = {
+ length: 1,
+ };
+
+ spyOn($.fn, 'init').and.callFake(selector => (selector === '.popover:hover' ? fakeJquery : $.fn));
+ spyOn(hidePopover, 'call');
+ mouseleave();
+ expect(hidePopover.call).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('mouseenter', () => {
+ const context = {};
+
+ it('shows popover', () => {
+ spyOn(showPopover, 'call').and.returnValue(false);
+ mouseenter.call(context);
+ expect(showPopover.call).toHaveBeenCalled();
+ });
+
+ it('registers mouseleave event if popover is showed', (done) => {
+ spyOn(showPopover, 'call').and.returnValue(true);
+ spyOn($.fn, 'on').and.callFake((eventName) => {
+ expect(eventName).toEqual('mouseleave');
+ done();
+ });
+ mouseenter.call(context);
+ });
+
+ it('does not register mouseleave event if popover is not showed', () => {
+ spyOn(showPopover, 'call').and.returnValue(false);
+ const spy = spyOn($.fn, 'on').and.callFake(() => {});
+ mouseenter.call(context);
+ expect(spy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('setupDismissButton', () => {
+ it('registers click event callback', (done) => {
+ const context = {
+ getAttribute: () => 'popoverId',
+ dataset: {
+ highlight: 'cookieId',
+ },
+ };
+
+ spyOn($.fn, 'on').and.callFake((event) => {
+ expect(event).toEqual('click');
+ done();
+ });
+ setupDismissButton.call(context);
+ });
+ });
+});
diff --git a/spec/javascripts/feature_highlight/feature_highlight_options_spec.js b/spec/javascripts/feature_highlight/feature_highlight_options_spec.js
new file mode 100644
index 00000000000..7feb361edec
--- /dev/null
+++ b/spec/javascripts/feature_highlight/feature_highlight_options_spec.js
@@ -0,0 +1,45 @@
+import domContentLoaded from '~/feature_highlight/feature_highlight_options';
+import bp from '~/breakpoints';
+
+describe('feature highlight options', () => {
+ describe('domContentLoaded', () => {
+ const highlightOrder = [];
+
+ beforeEach(() => {
+ // Check for when highlightFeatures is called
+ spyOn(highlightOrder, 'find').and.callFake(() => {});
+ });
+
+ it('should not call highlightFeatures when breakpoint is xs', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('xs');
+
+ domContentLoaded(highlightOrder);
+ expect(bp.getBreakpointSize).toHaveBeenCalled();
+ expect(highlightOrder.find).not.toHaveBeenCalled();
+ });
+
+ it('should not call highlightFeatures when breakpoint is sm', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
+
+ domContentLoaded(highlightOrder);
+ expect(bp.getBreakpointSize).toHaveBeenCalled();
+ expect(highlightOrder.find).not.toHaveBeenCalled();
+ });
+
+ it('should not call highlightFeatures when breakpoint is md', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('md');
+
+ domContentLoaded(highlightOrder);
+ expect(bp.getBreakpointSize).toHaveBeenCalled();
+ expect(highlightOrder.find).not.toHaveBeenCalled();
+ });
+
+ it('should call highlightFeatures when breakpoint is lg', () => {
+ spyOn(bp, 'getBreakpointSize').and.returnValue('lg');
+
+ domContentLoaded(highlightOrder);
+ expect(bp.getBreakpointSize).toHaveBeenCalled();
+ expect(highlightOrder.find).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/javascripts/feature_highlight/feature_highlight_spec.js b/spec/javascripts/feature_highlight/feature_highlight_spec.js
new file mode 100644
index 00000000000..6abe8425ee7
--- /dev/null
+++ b/spec/javascripts/feature_highlight/feature_highlight_spec.js
@@ -0,0 +1,122 @@
+import Cookies from 'js-cookie';
+import * as featureHighlightHelper from '~/feature_highlight/feature_highlight_helper';
+import * as featureHighlight from '~/feature_highlight/feature_highlight';
+
+describe('feature highlight', () => {
+ describe('setupFeatureHighlightPopover', () => {
+ const selector = '.js-feature-highlight[data-highlight=test]';
+ beforeEach(() => {
+ setFixtures(`
+ <div>
+ <div class="js-feature-highlight" data-highlight="test" disabled>
+ Trigger
+ </div>
+ </div>
+ <div class="feature-highlight-popover-content">
+ Content
+ <div class="dismiss-feature-highlight">
+ Dismiss
+ </div>
+ </div>
+ `);
+ spyOn(window, 'addEventListener');
+ spyOn(window, 'removeEventListener');
+ featureHighlight.setupFeatureHighlightPopover('test', 0);
+ });
+
+ it('setups popover content', () => {
+ const $popoverContent = $('.feature-highlight-popover-content');
+ const outerHTML = $popoverContent.prop('outerHTML');
+
+ expect($(selector).data('content')).toEqual(outerHTML);
+ });
+
+ it('setups mouseenter', () => {
+ const showSpy = spyOn(featureHighlightHelper.showPopover, 'call');
+ $(selector).trigger('mouseenter');
+
+ expect(showSpy).toHaveBeenCalled();
+ });
+
+ it('setups debounced mouseleave', (done) => {
+ const hideSpy = spyOn(featureHighlightHelper.hidePopover, 'call');
+ $(selector).trigger('mouseleave');
+
+ // Even though we've set the debounce to 0ms, setTimeout is needed for the debounce
+ setTimeout(() => {
+ expect(hideSpy).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('setups inserted.bs.popover', () => {
+ $(selector).trigger('mouseenter');
+ const popoverId = $(selector).attr('aria-describedby');
+ const spyEvent = spyOnEvent(`#${popoverId} .dismiss-feature-highlight`, 'click');
+
+ $(`#${popoverId} .dismiss-feature-highlight`).click();
+ expect(spyEvent).toHaveBeenTriggered();
+ });
+
+ it('setups show.bs.popover', () => {
+ $(selector).trigger('show.bs.popover');
+ expect(window.addEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function));
+ });
+
+ it('setups hide.bs.popover', () => {
+ $(selector).trigger('hide.bs.popover');
+ expect(window.removeEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function));
+ });
+
+ it('removes disabled attribute', () => {
+ expect($('.js-feature-highlight').is(':disabled')).toEqual(false);
+ });
+
+ it('displays popover', () => {
+ expect($(selector).attr('aria-describedby')).toBeFalsy();
+ $(selector).trigger('mouseenter');
+ expect($(selector).attr('aria-describedby')).toBeTruthy();
+ });
+ });
+
+ describe('shouldHighlightFeature', () => {
+ it('should return false if element is not found', () => {
+ spyOn(document, 'querySelector').and.returnValue(null);
+ spyOn(Cookies, 'get').and.returnValue(null);
+
+ expect(featureHighlight.shouldHighlightFeature()).toBeFalsy();
+ });
+
+ it('should return false if previouslyDismissed', () => {
+ spyOn(document, 'querySelector').and.returnValue(document.createElement('div'));
+ spyOn(Cookies, 'get').and.returnValue('true');
+
+ expect(featureHighlight.shouldHighlightFeature()).toBeFalsy();
+ });
+
+ it('should return true if element is found and not previouslyDismissed', () => {
+ spyOn(document, 'querySelector').and.returnValue(document.createElement('div'));
+ spyOn(Cookies, 'get').and.returnValue(null);
+
+ expect(featureHighlight.shouldHighlightFeature()).toBeTruthy();
+ });
+ });
+
+ describe('highlightFeatures', () => {
+ it('calls setupFeatureHighlightPopover if shouldHighlightFeature returns true', () => {
+ // Mimic shouldHighlightFeature set to true
+ const highlightOrder = ['issue-boards'];
+ spyOn(highlightOrder, 'find').and.returnValue(highlightOrder[0]);
+
+ expect(featureHighlight.highlightFeatures(highlightOrder)).toEqual(true);
+ });
+
+ it('does not call setupFeatureHighlightPopover if shouldHighlightFeature returns false', () => {
+ // Mimic shouldHighlightFeature set to false
+ const highlightOrder = ['issue-boards'];
+ spyOn(highlightOrder, 'find').and.returnValue(null);
+
+ expect(featureHighlight.highlightFeatures(highlightOrder)).toEqual(false);
+ });
+ });
+});