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.
* Organize tours by controller and action.
* Trigger tours automatically on page load or manually via JavaScript event.
* Plays nicely with Turbolinks.
* 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
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/
@ -92,11 +93,15 @@ config/
└── 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
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
intro:
@ -129,7 +134,7 @@ When you specify an `attachTo` element, use the `placement` option to choose whe
* `bottom left`
* `bottom right`
* `center` / `middle` / `middle center`
* `left` / `middle left'
* `left` / `middle left`
* `right` / `middle right`
* `top` / `top center`
* `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 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
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 shepherd.js/dist/js/shepherd
var abrahamReady = (callback) => {
if (document.readyState != "loading") callback();
else document.addEventListener("DOMContentLoaded", callback);
}
document.addEventListener('turbolinks:before-cache', function() {
// Remove visible product tours
document.querySelectorAll(".shepherd-element").forEach(function(el) { el.remove() });

View File

@ -1,26 +1,57 @@
# frozen_string_literal: true
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
# Do we have tours for this controller/action in the user's 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}"]
if tours
# Have any automatic tours been completed already?
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"] })
tour_keys_completed = completed.map(&:tour_name)
tour_keys = tours.keys
tour_html = ''
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
tour_html.html_safe
end
end

View File

@ -1,7 +1,8 @@
<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
return fetch("/abraham_histories/", {
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 %>' });
});
<% end %>
<% steps.each_with_index do |(key, step), index| %>
tour.addStep({
abraham_tour_<%= tour_name %>.addStep({
id: 'step-<%= key %>',
<% if step.key?('title') %>
title: "<%= step['title'] %>",
@ -34,35 +36,45 @@
},
<% end %>
buttons: [
<% if index == 0 %>
{ text: '<%= t('abraham.later') %>', action: tour.cancel, classes: 'shepherd-button-secondary' },
{ text: '<%= t('abraham.continue') %>', action: tour.next }
<% if index == steps.size - 1 %>
{ text: '<%= t('abraham.done') %>', action: abraham_tour_<%= tour_name %>.complete }
<% else %>
<% if index == steps.size - 1 %>
{ text: '<%= t('abraham.done') %>', action: tour.complete }
<% else %>
{ text: '<%= t('abraham.exit') %>', action: tour.cancel, classes: 'shepherd-button-secondary' },
{ text: '<%= t('abraham.next') %>', action: tour.next }
<% end %>
<% if index == 0 %>
{ text: '<%= t('abraham.later') %>', action: abraham_tour_<%= tour_name %>.cancel, classes: 'shepherd-button-secondary' },
{ text: '<%= t('abraham.continue') %>', action: abraham_tour_<%= tour_name %>.next }
<% else %>
{ text: '<%= t('abraham.exit') %>', action: abraham_tour_<%= tour_name %>.cancel, classes: 'shepherd-button-secondary' },
{ text: '<%= t('abraham.next') %>', action: abraham_tour_<%= tour_name %>.next }
<% end %>
<% end %>
]
});
<% end %>
tour.start = function (start) {
return function () {
// Don't start the tour if the user dismissed it once this session
var tourMayStart = !Cookies.get('<%= abraham_cookie_prefix %>-<%= tour_name %>', {domain: '<%= abraham_domain %>'});
<% if steps.first[1]['attachTo'] %>
// Dispatch this event to do cookie and element checks before starting (default)
document.addEventListener('abraham:<%= tour_name %>:start', function() {
// Don't start tour if a cookie says not to
var tourMayStart = !Cookies.get('<%= abraham_cookie_prefix %>-<%= tour_name %>', {domain: '<%= abraham_domain %>'});
<% if steps.first[1]['attachTo'] %>
// Don't start the tour if the first step's element is missing
tourMayStart = tourMayStart && document.querySelector("<%= steps.first[1]['attachTo']['element'] %>");
<% end %>
if (tourMayStart) {
start();
}
<% end %>
if (tourMayStart) {
document.dispatchEvent(new Event('abraham:<%= tour_name %>:startNow'));
}
}(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>

View File

@ -5,4 +5,17 @@
a content element to notice
</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 %>

View File

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

View File

@ -17,3 +17,13 @@ intro:
attachTo:
element: ".notice-me"
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"
placement: "top"
tour_two:
steps:
1:
title: "TOUR TWO step one ENGLISH"
text: "we show this on your second visit"
# tour_two:
# steps:
# 1:
# title: "TOUR TWO step one ENGLISH"
# text: "we show this on your second visit"

View File

@ -17,6 +17,11 @@ class ToursTest < ApplicationSystemTestCase
assert_selector ".shepherd-button", text: "Continue"
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
assert_selector ".shepherd-header", text: "ENGLISH This step has a title"
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
visit dashboard_home_url
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
test "mark a tour for Later and it will not come back in this session" do