Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

Commit

Permalink
✨ welcome tour (#527)
Browse files Browse the repository at this point in the history
refs TryGhost/Ghost#5168
- adds a `tour` service that handles syncing and management of tour throbbers & content
- adds a `gh-tour-item` component that handles the display of a throbber and it's associated popover when clicked
- uses settings API endpoint to populate viewed tour items on app boot/signin
- adds `liquid-tether@2.0.3` dependency for attaching throbbers and popups
- adds initial tour contents
  • Loading branch information
kevinansfield authored and aileen committed Jun 8, 2017
1 parent 3d0730a commit d8e1375
Show file tree
Hide file tree
Showing 21 changed files with 922 additions and 9 deletions.
33 changes: 32 additions & 1 deletion app/components/gh-post-settings-menu.js
Expand Up @@ -10,9 +10,12 @@ import moment from 'moment';
import {guidFor} from 'ember-metal/utils';
import {htmlSafe} from 'ember-string';
import {invokeAction} from 'ember-invoke-action';
import {task, timeout} from 'ember-concurrency';

const {Handlebars} = Ember;

const PSM_ANIMATION_LENGTH = 400;

export default Component.extend(SettingsMenuMixin, {
selectedAuthor: null,
authors: [],
Expand All @@ -31,6 +34,7 @@ export default Component.extend(SettingsMenuMixin, {
metaDescriptionScratch: alias('model.metaDescriptionScratch'),

_showSettingsMenu: false,
_showThrobbers: false,

didReceiveAttrs() {
this._super(...arguments);
Expand All @@ -43,20 +47,37 @@ export default Component.extend(SettingsMenuMixin, {
this.set('selectedAuthor', author);
});

// reset the publish date on close if it has an error
// HACK: ugly method of working around the CSS animations so that we
// can add throbbers only when the animation has finished
// TODO: use liquid-fire to handle PSM slide-in and replace tabs manager
// with something more ember-like
if (this.get('showSettingsMenu') && !this._showSettingsMenu) {
this.get('showThrobbers').perform();
}

// fired when menu is closed
if (!this.get('showSettingsMenu') && this._showSettingsMenu) {
let post = this.get('model');
let errors = post.get('errors');

// reset the publish date if it has an error
if (errors.has('publishedAtBlogDate') || errors.has('publishedAtBlogTime')) {
post.set('publishedAtBlogTZ', post.get('publishedAtUTC'));
post.validate({attribute: 'publishedAtBlog'});
}

// remove throbbers
this.set('_showThrobbers', false);
}

this._showSettingsMenu = this.get('showSettingsMenu');
},

showThrobbers: task(function* () {
yield timeout(PSM_ANIMATION_LENGTH);
this.set('_showThrobbers', true);
}).restartable(),

seoTitle: computed('model.titleScratch', 'metaTitleScratch', function () {
let metaTitle = this.get('metaTitleScratch') || '';

Expand Down Expand Up @@ -133,6 +154,16 @@ export default Component.extend(SettingsMenuMixin, {
},

actions: {
showSubview() {
this._super(...arguments);
this.set('_showThrobbers', false);
},

closeSubview() {
this._super(...arguments);
this.get('showThrobbers').perform();
},

discardEnter() {
return false;
},
Expand Down
171 changes: 171 additions & 0 deletions app/components/gh-tour-item.js
@@ -0,0 +1,171 @@
import Component from 'ember-component';
import computed, {reads} from 'ember-computed';
import injectService from 'ember-service/inject';
import run from 'ember-runloop';
import {isBlank} from 'ember-utils';

let instancesCounter = 0;

let triangleClassPositions = {
'top-left': {
attachment: 'top left',
targetAttachment: 'bottom center',
offset: '0 28px'
},
'top': {
attachment: 'top center',
targetAttachment: 'bottom center'
},
'top-right': {
attachment: 'top right',
targetAttachment: 'bottom center',
offset: '0 -28px'
},
'right-top': {
attachment: 'top right',
targetAttachment: 'middle left',
offset: '28px 0'
},
'right': {
attachment: 'middle right',
targetAttachment: 'middle left'
},
'right-bottom': {
attachment: 'bottom right',
targetAttachment: 'middle left',
offset: '-28px 0'
},
'bottom-right': {
attachment: 'bottom right',
targetAttachment: 'top center',
offset: '0 -28px'
},
'bottom': {
attachment: 'bottom center',
targetAttachment: 'top center'
},
'bottom-left': {
attachment: 'bottom left',
targetAttachment: 'top center',
offset: '0 28px'
},
'left-bottom': {
attachment: 'bottom left',
targetAttachment: 'middle right',
offset: '-28px 0'
},
'left': {
attachment: 'middle left',
targetAttachment: 'middle right'
},
'left-top': {
attachment: 'top left',
targetAttachment: 'middle right',
offset: '28px 0'
}
};

const GhTourItemComponent = Component.extend({

mediaQueries: injectService(),
tour: injectService(),

tagName: '',

throbberId: null,
target: null,
throbberAttachment: 'middle center',
popoverTriangleClass: 'top',
isOpen: false,

_elementId: null,
_throbber: null,
_throbberElementId: null,
_throbberElementSelector: null,
_popoverAttachment: null,
_popoverTargetAttachment: null,
_popoverOffset: null,

isMobile: reads('mediaQueries.isMobile'),
isVisible: computed('isMobile', '_throbber', function () {
let isMobile = this.get('isMobile');
let hasThrobber = !isBlank(this.get('_throbber'));

return !isMobile && hasThrobber;
}),

init() {
this._super(...arguments);
// this is a tagless component so we need to generate our own elementId
this._elementId = instancesCounter++;
this._throbberElementId = `throbber-${this._elementId}`;
this._throbberElementSelector = `#throbber-${this._elementId}`;

this._handleOptOut = run.bind(this, this._remove);
this._handleViewed = run.bind(this, this._removeIfViewed);

this.get('tour').on('optOut', this._handleOptOut);
this.get('tour').on('viewed', this._handleViewed);
},

didReceiveAttrs() {
let throbberId = this.get('throbberId');
let throbber = this.get('tour').activeThrobber(throbberId);
let triangleClass = this.get('popoverTriangleClass');
let popoverPositions = triangleClassPositions[triangleClass];

this._throbber = throbber;
this._popoverAttachment = popoverPositions.attachment;
this._popoverTargetAttachment = popoverPositions.targetAttachment;
this._popoverOffset = popoverPositions.offset;
},

willDestroyElement() {
this._super(...arguments);
this.get('tour').off('optOut', this._handleOptOut);
this.get('tour').off('viewed', this._handleOptOut);
},

_removeIfViewed(id) {
if (id === this.get('throbberId')) {
this._remove();
}
},

_remove() {
this.set('_throbber', null);
},

_close() {
this.set('isOpen', false);
},

actions: {
open() {
this.set('isOpen', true);
},

close() {
this._close();
},

markAsViewed() {
let throbberId = this.get('throbberId');
this.get('tour').markThrobberAsViewed(throbberId);
this.set('_throbber', null);
this._close();
},

optOut() {
this.get('tour').optOut();
this.set('_throbber', null);
this._close();
}
}
});

GhTourItemComponent.reopenClass({
positionalParams: ['throbberId']
});

export default GhTourItemComponent;
1 change: 1 addition & 0 deletions app/models/user.js
Expand Up @@ -35,6 +35,7 @@ export default Model.extend(ValidationEngine, {
count: attr('raw'),
facebook: attr('facebook-url-user'),
twitter: attr('twitter-url-user'),
tour: attr('json-string'),

ghostPaths: injectService(),
ajax: injectService(),
Expand Down
5 changes: 4 additions & 1 deletion app/routes/application.js
Expand Up @@ -34,6 +34,7 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
notifications: injectService(),
settings: injectService(),
upgradeNotification: injectService(),
tour: injectService(),

beforeModel() {
return this.get('config').fetch();
Expand Down Expand Up @@ -64,12 +65,14 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
});

let settingsPromise = this.get('settings').fetch();
let tourPromise = this.get('tour').fetchViewed();

// return the feature/settings load promises so that we block until
// they are loaded to enable synchronous access everywhere
return RSVP.all([
featurePromise,
settingsPromise
settingsPromise,
tourPromise
]);
}
},
Expand Down
12 changes: 10 additions & 2 deletions app/services/session.js
@@ -1,18 +1,26 @@
import RSVP from 'rsvp';
import SessionService from 'ember-simple-auth/services/session';
import computed from 'ember-computed';
import injectService from 'ember-service/inject';

export default SessionService.extend({
store: injectService(),
feature: injectService(),
store: injectService(),
tour: injectService(),

user: computed(function () {
return this.get('store').queryRecord('user', {id: 'me'});
}),

authenticate() {
return this._super(...arguments).then((authResult) => {
return this.get('feature').fetch().then(() => {
// TODO: remove duplication with application.afterModel
let preloadPromises = [
this.get('feature').fetch(),
this.get('tour').fetchViewed()
];

return RSVP.all(preloadPromises).then(() => {
return authResult;
});
});
Expand Down

0 comments on commit d8e1375

Please sign in to comment.