Compare commits

...

6 Commits

Author SHA1 Message Date
Jonathan Abbett
0a55cd0e83 Final tweak to the README. 2021-04-21 14:55:54 -04:00
Jonathan Abbett
3d21f3a092 Improved cleanup on Turbolinks navigation 2021-04-20 14:59:45 -04:00
Jonathan Abbett
8663c2ed6c Fix link position on test page 2021-04-16 16:25:16 -04:00
Jonathan Abbett
ae06540ea0 Better handling of tour triggering 2021-04-16 16:09:56 -04:00
Jonathan Abbett
5917e0a6a9 Final touches, updates to README 2021-04-15 21:44:04 -04:00
Jonathan Abbett
c3d20b9cb7 New: Manually triggered tours 2021-04-15 21:06:32 -04:00
9 changed files with 186 additions and 45 deletions

View File

@ -6,12 +6,13 @@ _Guide your users in the one true path._
![Watercolor Sheep](https://upload.wikimedia.org/wikipedia/commons/e/e4/Watercolor_Sheep_Drawing.jpg) ![Watercolor Sheep](https://upload.wikimedia.org/wikipedia/commons/e/e4/Watercolor_Sheep_Drawing.jpg)
Abraham injects dynamically-generated [Shepherd](https://shepherdjs.dev/) JavaScript code into your Rails application whenever a user should see a guided tour. Skip a tour, and we'll try again next time; complete a tour, and it won't show up again. Abraham makes it easy to show guided tours to users of your Rails application. When Abraham shows a tour, it keeps track of whether the user has completed it (so it doesn't get shown again) or dismissed it for later (so it reappears in a future user session).
* 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.
* Plays nicely with Turbolinks. * Trigger tours automatically on page load or manually via JavaScript method.
* Ships with two basic CSS themes (default & dark) -- or write your own * Built with the [Shepherd JS](https://shepherdjs.dev/) library. Plays nicely with Turbolinks.
* Ships with two basic CSS themes (default & dark) — or write your own
## Requirements ## Requirements
@ -38,7 +39,7 @@ $ rails db:migrate
Install the JavaScript dependencies: Install the JavaScript dependencies:
``` ```
$ yarn add jquery@^3.4.0 js-cookie@^2.2.0 shepherd.js@^6.0.0-beta $ yarn add js-cookie@^2.2.0 shepherd.js@^6.0.0-beta
``` ```
Require `abraham` in `app/assets/javascripts/application.js` Require `abraham` in `app/assets/javascripts/application.js`
@ -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 mirrors your application's controllers, and the tour files 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 `text`. 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 start 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..."
```
This tour will not start automatically; instead, use the `Abraham.startTour` method with the tour name:
```
<button id="startTour">Start tour</button>
<script>
document.querySelector("#startTour").addEventListener("click", function() {
Abraham.startTour("walkthrough"));
});
</script>
```
...or if you happen to use jQuery:
```
<script>
$("#startTour").on("click", function() { Abraham.startTour('walkthrough'); })
</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.
@ -154,11 +191,11 @@ end
## Full example ## Full example
We provide a [small example app](https://github.com/actmd/abraham-example) that implements abraham, so you can see it in action. We provide a [small example app](https://github.com/actmd/abraham-example) that implements Abraham, so you can see it in action.
## Upgrading from version 1 ## Upgrading from version 1
Abraham v1 was built using Shepherd 1.8, v2 now uses Shepherd 6 -- quite a jump, yes. Abraham v1 was built using Shepherd 1.8, v2 now uses Shepherd 6 quite a jump, yes.
If you were using Abraham v1, you'll want to take the following steps to upgrade: If you were using Abraham v1, you'll want to take the following steps to upgrade:

View File

@ -1,7 +1,28 @@
//= 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 Abraham = new Object();
Abraham.tours = {};
Abraham.incompleteTours = [];
Abraham.startTour = function(tourName) {
if (!Shepherd.activeTour) {
Abraham.tours[tourName].start();
}
};
Abraham.startNextIncompleteTour = function() {
if (Abraham.incompleteTours.length) {
Abraham.tours[Abraham.incompleteTours[0]].checkAndStart();
}
};
document.addEventListener("DOMContentLoaded", Abraham.startNextIncompleteTour);
document.addEventListener("turbolinks:load", Abraham.startNextIncompleteTour);
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() });
// Clear Abraham data
Abraham.tours = {};
Abraham.incompleteTours = [];
}); });

View File

@ -4,23 +4,31 @@ module AbrahamHelper
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? %>); Abraham.tours["<%= 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.tours["<%= 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.tours["<%= 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.tours["<%= tour_name %>"].addStep({
id: 'step-<%= key %>', id: 'step-<%= key %>',
<% if step.key?('title') %> <% if step.key?('title') %>
title: "<%= step['title'] %>", title: "<%= step['title'] %>",
@ -34,22 +36,23 @@
}, },
<% end %> <% end %>
buttons: [ buttons: [
<% if index == 0 %>
{ text: '<%= t('abraham.later') %>', action: tour.cancel, classes: 'shepherd-button-secondary' },
{ text: '<%= t('abraham.continue') %>', action: tour.next }
<% else %>
<% if index == steps.size - 1 %> <% if index == steps.size - 1 %>
{ text: '<%= t('abraham.done') %>', action: tour.complete } { text: '<%= t('abraham.done') %>', action: Abraham.tours["<%= tour_name %>"].complete }
<% else %> <% else %>
{ text: '<%= t('abraham.exit') %>', action: tour.cancel, classes: 'shepherd-button-secondary' }, <% if index == 0 %>
{ text: '<%= t('abraham.next') %>', action: tour.next } { text: '<%= t('abraham.later') %>', action: Abraham.tours["<%= tour_name %>"].cancel, classes: 'shepherd-button-secondary' },
{ text: '<%= t('abraham.continue') %>', action: Abraham.tours["<%= tour_name %>"].next }
<% else %>
{ text: '<%= t('abraham.exit') %>', action: Abraham.tours["<%= tour_name %>"].cancel, classes: 'shepherd-button-secondary' },
{ text: '<%= t('abraham.next') %>', action: Abraham.tours["<%= tour_name %>"].next }
<% end %> <% end %>
<% end %> <% end %>
] ]
}); });
<% end %> <% end %>
tour.start = function (start) { <% if trigger != "manual" %>
Abraham.tours["<%= tour_name %>"].checkAndStart = function (start) {
return function () { return function () {
// Don't start the tour if the user dismissed it once this session // 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 %>'}); var tourMayStart = !Cookies.get('<%= abraham_cookie_prefix %>-<%= tour_name %>', {domain: '<%= abraham_domain %>'});
@ -62,7 +65,11 @@
start(); start();
} }
} }
}(tour.start) }(Abraham.tours["<%= tour_name %>"].start)
<% if !tour_completed %>
Abraham.incompleteTours.push("<%= tour_name %>");
<% end %>
<% end %>
tour.start()
</script> </script>

View File

@ -1,8 +1,26 @@
<h1>Dashboard#home</h1> <h1>Dashboard#home</h1>
<p>Find me in app/views/dashboard/home.html.erb</p> <p>Find me in app/views/dashboard/home.html.erb</p>
<%= link_to "Other Page", dashboard_other_url %>
<div class="notice-me" style="width:300px;height:300px;background-color:whitesmoke;"> <div class="notice-me" style="width:300px;height:300px;background-color:whitesmoke;">
a content element to notice a content element to notice
</div> </div>
<%= link_to "Other Page", dashboard_other_url %> <button id="restart_automatic">Restart the automatic tour</button>
<button id="show_manual">Show manual tour</button>
<button id="show_another_manual">Show ANOTHER manual tour</button>
<script>
document.querySelector("#restart_automatic").addEventListener("click", function() {
Abraham.startTour("intro");
});
document.querySelector("#show_manual").addEventListener("click", function() {
Abraham.startTour("a_manual_tour");
});
document.querySelector("#show_another_manual").addEventListener("click", function() {
Abraham.startTour("another_manual_tour");
});
</script>

View File

@ -1,2 +1,4 @@
<h1>Dashboard#other</h1> <h1>Dashboard#other</h1>
<p>Find me in app/views/dashboard/other.html.erb</p> <p>Find me in app/views/dashboard/other.html.erb</p>
<%= link_to "Home Page", dashboard_home_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

@ -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
@ -77,4 +93,22 @@ class ToursTest < ApplicationSystemTestCase
# No tour should be visible, since the first step is invalid # No tour should be visible, since the first step is invalid
refute_selector ".shepherd-element" refute_selector ".shepherd-element"
end end
test "page with two incomplete tours shows them on consecutive visits" do
# First tour should appear at first visit
visit dashboard_other_url
assert_selector ".shepherd-element", visible: true
assert_selector ".shepherd-header", text: "TOUR ONE step one ENGLISH"
find(".shepherd-button", text: "Done").click
# Second tour should appear at second visit
visit dashboard_other_url
assert_selector ".shepherd-element", visible: true
assert_selector ".shepherd-header", text: "TOUR TWO step one ENGLISH"
find(".shepherd-button", text: "Done").click
# Now no tours should appear since they're both done
visit dashboard_other_url
refute_selector ".shepherd-element"
end
end end