New: Manually triggered tours

This commit is contained in:
Jonathan Abbett 2021-04-15 21:06:32 -04:00
parent 5e2854d969
commit c3d20b9cb7
9 changed files with 169 additions and 41 deletions

View File

@ -10,6 +10,7 @@ Abraham injects dynamically-generated [Shepherd](https://shepherdjs.dev/) JavaSc
* Define tour content with simple YAML files, in any/many languages. * Define tour content with simple YAML files, in any/many languages.
* Organize tours by controller and action. * Organize tours by controller and action.
* Trigger tours automatically on page load or manually via JavaScript event.
* Plays nicely with Turbolinks. * Plays nicely with Turbolinks.
* Ships with two basic CSS themes (default & dark) -- or write your own * Ships with two basic CSS themes (default & dark) -- or write your own
@ -77,7 +78,7 @@ Tell Abraham where to insert its generated JavaScript in `app/views/layouts/appl
## Defining your tours ## Defining your tours
Define your tours in the `config/tours` directory. Its directory structure should mirror your application's controllers, and the tour files should mirror your actions/views. Define your tours in the `config/tours` directory corresponding to the views defined in your application. Its directory structure should mirror your application's controllers, and the tour files should mirror your actions/views.
``` ```
config/ config/
@ -92,11 +93,15 @@ config/
└── show.es.yml └── show.es.yml
``` ```
NB: You must specify a locale in the filename, even if you're only supporting one language. For example, per above, when a Spanish-speaking user visits `/articles/`, they'll see the tours defined by `config/tours/articles/index.es.yml`.
(Note: You must specify a locale in the filename, even if you're only supporting one language.)
### Tour content ### Tour content
A tour is composed of a series of steps. A step may have a title and must have a description. You may attach a step to a particular element on the page, and place the callout in a particular position (see below). Within a tour file, each tour is composed of a series of **steps**. A step may have a title and must have a description. You may attach a step to a particular element on the page, and place the callout in a particular position.
In this example, we define a tour called "intro" with 3 steps:
```yaml ```yaml
intro: intro:
@ -129,7 +134,7 @@ When you specify an `attachTo` element, use the `placement` option to choose whe
* `bottom left` * `bottom left`
* `bottom right` * `bottom right`
* `center` / `middle` / `middle center` * `center` / `middle` / `middle center`
* `left` / `middle left' * `left` / `middle left`
* `right` / `middle right` * `right` / `middle right`
* `top` / `top center` * `top` / `top center`
* `top left` * `top left`
@ -140,6 +145,38 @@ Abraham tries to be helpful when your tour steps attach to page elements that ar
* If your first step is attached to a particular element, and that element is not present on the page, the tour won't start. ([#28](https://github.com/actmd/abraham/issues/28)) * If your first step is attached to a particular element, and that element is not present on the page, the tour won't start. ([#28](https://github.com/actmd/abraham/issues/28))
* If your tour has an intermediate step attached to a missing element, Abraham will skip that step and automatically show the next. ([#6](https://github.com/actmd/abraham/issues/6)) * If your tour has an intermediate step attached to a missing element, Abraham will skip that step and automatically show the next. ([#6](https://github.com/actmd/abraham/issues/6))
### Automatic vs. manual tours
By default, Abraham will automatically trigger a tour that the current user hasn't seen yet. You can instead define a tour to be triggered manually using the `trigger` option:
```yml
walkthrough:
trigger: "manual"
steps:
1:
text: "This walkthrough will show you how to..."
```
Abraham creates a JavaScript event based on the tour name that you can wire into a link or button on that page. In the above example, you would use the `abraham:walthrough:startNow` event to make the tour appear:
```
<button id="startTour">Start tour</button>
<script>
document.querySelector("#startTour").addEventListener("click", function() {
document.dispatchEvent(new Event('abraham:walthrough:startNow'));
});
</script>
```
...or if you use jQuery:
```
<script>
$("#startTour").on("click", function() { $(document).trigger('abraham:walthrough:startNow'); })
</script>
```
### Testing your tours ### Testing your tours
Abraham loads tour definitions once when you start your server. Restart your server to see tour changes. Abraham loads tour definitions once when you start your server. Restart your server to see tour changes.

View File

@ -1,6 +1,11 @@
//= require js-cookie/src/js.cookie //= require js-cookie/src/js.cookie
//= require shepherd.js/dist/js/shepherd //= require shepherd.js/dist/js/shepherd
var abrahamReady = (callback) => {
if (document.readyState != "loading") callback();
else document.addEventListener("DOMContentLoaded", callback);
}
document.addEventListener('turbolinks:before-cache', function() { document.addEventListener('turbolinks:before-cache', function() {
// Remove visible product tours // Remove visible product tours
document.querySelectorAll(".shepherd-element").forEach(function(el) { el.remove() }); document.querySelectorAll(".shepherd-element").forEach(function(el) { el.remove() });

View File

@ -1,26 +1,57 @@
# frozen_string_literal: true # frozen_string_literal: true
module AbrahamHelper module AbrahamHelper
# def abraham_tour
# # Do we have tours for this controller/action in the user's locale?
# tours = Rails.configuration.abraham.tours["#{controller_name}.#{action_name}.#{I18n.locale}"]
# tours ||= Rails.configuration.abraham.tours["#{controller_name}.#{action_name}.#{I18n.default_locale}"]
# if tours
# completed = AbrahamHistory.where(
# creator_id: current_user.id,
# controller_name: controller_name,
# action_name: action_name
# )
# remaining = tours.keys - completed.map(&:tour_name)
# if remaining.any?
# # Generate the javascript snippet for the next remaining tour
# render(partial: "application/abraham",
# locals: { tour_name: remaining.first,
# steps: tours[remaining.first]["steps"] })
# end
# end
# end
def abraham_tour def abraham_tour
# Do we have tours for this controller/action in the user's locale? # Do we have tours for this controller/action in the user's locale?
tours = Rails.configuration.abraham.tours["#{controller_name}.#{action_name}.#{I18n.locale}"] tours = Rails.configuration.abraham.tours["#{controller_name}.#{action_name}.#{I18n.locale}"]
# Otherwise, default to the default locale
tours ||= Rails.configuration.abraham.tours["#{controller_name}.#{action_name}.#{I18n.default_locale}"] tours ||= Rails.configuration.abraham.tours["#{controller_name}.#{action_name}.#{I18n.default_locale}"]
if tours if tours
# Have any automatic tours been completed already?
completed = AbrahamHistory.where( completed = AbrahamHistory.where(
creator_id: current_user.id, creator_id: current_user.id,
controller_name: controller_name, controller_name: controller_name,
action_name: action_name action_name: action_name
) )
remaining = tours.keys - completed.map(&:tour_name)
if remaining.any? tour_keys_completed = completed.map(&:tour_name)
# Generate the javascript snippet for the next remaining tour tour_keys = tours.keys
render(partial: "application/abraham",
locals: { tour_name: remaining.first, tour_html = ''
steps: tours[remaining.first]["steps"] })
tour_keys.each do |key|
tour_html += render(partial: "application/abraham",
locals: { tour_name: key,
tour_completed: tour_keys_completed.include?(key),
trigger: tours[key]["trigger"],
steps: tours[key]["steps"] })
end end
tour_html.html_safe
end end
end end

View File

@ -1,7 +1,8 @@
<script> <script>
var tour = new Shepherd.Tour(<%= Rails.configuration.abraham.tour_options.html_safe unless Rails.configuration.abraham.tour_options.nil? %>); var abraham_tour_<%= tour_name %> = new Shepherd.Tour(<%= Rails.configuration.abraham.tour_options.html_safe unless Rails.configuration.abraham.tour_options.nil? %>);
tour.on("complete", function() { <% if trigger != 'manual' %>
abraham_tour_<%= tour_name %>.on("complete", function() {
// Make AJAX call to save history of tour completion // Make AJAX call to save history of tour completion
return fetch("/abraham_histories/", { return fetch("/abraham_histories/", {
method: "POST", method: "POST",
@ -15,12 +16,13 @@
}); });
}); });
tour.on("cancel", function() { abraham_tour_<%= tour_name %>.on("cancel", function() {
Cookies.set('<%= abraham_cookie_prefix %>-<%= tour_name %>', 'later', { domain: '<%= abraham_domain %>' }); Cookies.set('<%= abraham_cookie_prefix %>-<%= tour_name %>', 'later', { domain: '<%= abraham_domain %>' });
}); });
<% end %>
<% steps.each_with_index do |(key, step), index| %> <% steps.each_with_index do |(key, step), index| %>
tour.addStep({ abraham_tour_<%= tour_name %>.addStep({
id: 'step-<%= key %>', id: 'step-<%= key %>',
<% if step.key?('title') %> <% if step.key?('title') %>
title: "<%= step['title'] %>", title: "<%= step['title'] %>",
@ -34,35 +36,45 @@
}, },
<% end %> <% end %>
buttons: [ buttons: [
<% if index == 0 %> <% if index == steps.size - 1 %>
{ text: '<%= t('abraham.later') %>', action: tour.cancel, classes: 'shepherd-button-secondary' }, { text: '<%= t('abraham.done') %>', action: abraham_tour_<%= tour_name %>.complete }
{ text: '<%= t('abraham.continue') %>', action: tour.next }
<% else %> <% else %>
<% if index == steps.size - 1 %> <% if index == 0 %>
{ text: '<%= t('abraham.done') %>', action: tour.complete } { text: '<%= t('abraham.later') %>', action: abraham_tour_<%= tour_name %>.cancel, classes: 'shepherd-button-secondary' },
<% else %> { text: '<%= t('abraham.continue') %>', action: abraham_tour_<%= tour_name %>.next }
{ text: '<%= t('abraham.exit') %>', action: tour.cancel, classes: 'shepherd-button-secondary' }, <% else %>
{ text: '<%= t('abraham.next') %>', action: tour.next } { text: '<%= t('abraham.exit') %>', action: abraham_tour_<%= tour_name %>.cancel, classes: 'shepherd-button-secondary' },
<% end %> { text: '<%= t('abraham.next') %>', action: abraham_tour_<%= tour_name %>.next }
<% end %>
<% end %> <% end %>
] ]
}); });
<% end %> <% end %>
tour.start = function (start) { // Dispatch this event to do cookie and element checks before starting (default)
return function () { document.addEventListener('abraham:<%= tour_name %>:start', function() {
// Don't start the tour if the user dismissed it once this session // Don't start tour if a cookie says not to
var tourMayStart = !Cookies.get('<%= abraham_cookie_prefix %>-<%= tour_name %>', {domain: '<%= abraham_domain %>'}); var tourMayStart = !Cookies.get('<%= abraham_cookie_prefix %>-<%= tour_name %>', {domain: '<%= abraham_domain %>'});
<% if steps.first[1]['attachTo'] %> <% if steps.first[1]['attachTo'] %>
// Don't start the tour if the first step's element is missing // Don't start the tour if the first step's element is missing
tourMayStart = tourMayStart && document.querySelector("<%= steps.first[1]['attachTo']['element'] %>"); tourMayStart = tourMayStart && document.querySelector("<%= steps.first[1]['attachTo']['element'] %>");
<% end %> <% end %>
if (tourMayStart) {
if (tourMayStart) { document.dispatchEvent(new Event('abraham:<%= tour_name %>:startNow'));
start();
}
} }
}(tour.start) });
tour.start() // Dispatch this event to start the tour manually
document.addEventListener('abraham:<%= tour_name %>:startNow', function() {
// Don't start the tour if another one is already active on the screen
if (!Shepherd.activeTour) {
abraham_tour_<%= tour_name %>.start();
}
});
<% if !tour_completed && trigger != 'manual' %>
abrahamReady(function() {
document.dispatchEvent(new Event('abraham:<%= tour_name %>:start'));
});
<% end %>
</script> </script>

View File

@ -5,4 +5,17 @@
a content element to notice a content element to notice
</div> </div>
<button id="show_manual">Show manual tour</button>
<button id="show_another_manual">Show ANOTHER manual tour</button>
<script>
document.querySelector("#show_manual").addEventListener("click", function() {
document.dispatchEvent(new Event('abraham:a_manual_tour:startNow'));
});
document.querySelector("#show_another_manual").addEventListener("click", function() {
document.dispatchEvent(new Event('abraham:another_manual_tour:startNow'));
});
</script>
<%= link_to "Other Page", dashboard_other_url %> <%= link_to "Other Page", dashboard_other_url %>

View File

@ -11,6 +11,10 @@
<body> <body>
<%= yield %> <%= yield %>
<hr>
<p><em>current_user.id = <%= current_user.id %></em></p>
<%= abraham_tour %> <%= abraham_tour %>
</body> </body>
</html> </html>

View File

@ -17,3 +17,13 @@ intro:
attachTo: attachTo:
element: ".notice-me" element: ".notice-me"
placement: "right" placement: "right"
a_manual_tour:
trigger: manual
steps:
1:
text: "You triggered the manual tour"
another_manual_tour:
trigger: manual
steps:
1:
text: "You triggered the OTHER manual tour"

View File

@ -7,8 +7,8 @@ tour_one:
element: "p" element: "p"
placement: "top" placement: "top"
tour_two: # tour_two:
steps: # steps:
1: # 1:
title: "TOUR TWO step one ENGLISH" # title: "TOUR TWO step one ENGLISH"
text: "we show this on your second visit" # text: "we show this on your second visit"

View File

@ -17,6 +17,11 @@ class ToursTest < ApplicationSystemTestCase
assert_selector ".shepherd-button", text: "Continue" assert_selector ".shepherd-button", text: "Continue"
find(".shepherd-button", text: "Continue").click find(".shepherd-button", text: "Continue").click
# Now try to manually trigger another tour
find('#show_manual').click
# Even though we triggered another tour, it should not appear since one is already active
assert_selector ".shepherd-element", count: 1, visible: true
# Tour Step 2 # Tour Step 2
assert_selector ".shepherd-header", text: "ENGLISH This step has a title" assert_selector ".shepherd-header", text: "ENGLISH This step has a title"
assert_selector ".shepherd-text", text: "ENGLISH This intermediate step has some text" assert_selector ".shepherd-text", text: "ENGLISH This intermediate step has some text"
@ -40,6 +45,17 @@ class ToursTest < ApplicationSystemTestCase
# Tour should not reappear on reload # Tour should not reappear on reload
visit dashboard_home_url visit dashboard_home_url
refute_selector ".shepherd-element" refute_selector ".shepherd-element"
# Now start a manual tour
find('#show_manual').click
assert_selector ".shepherd-element", visible: true
assert_selector ".shepherd-text", text: "You triggered the manual tour"
assert_selector ".shepherd-button", text: "Done"
find(".shepherd-button", text: "Done").click
# Even though we finished the manual tour, we can start it again right away
find('#show_manual').click
assert_selector ".shepherd-element", visible: true
end end
test "mark a tour for Later and it will not come back in this session" do test "mark a tour for Later and it will not come back in this session" do