Commit 89bc4044 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Reimplement Help/Tutorial panel

parent 7953c6a4
'use strict'; 'use strict';
const { createElement } = require('preact');
const { useCallback, useMemo, useState } = require('preact/hooks');
const propTypes = require('prop-types');
const uiConstants = require('../ui-constants');
const useStore = require('../store/use-store');
const VersionData = require('../util/version-data');
const { withServices } = require('../util/service-context');
const SidebarPanel = require('./sidebar-panel');
const SvgIcon = require('./svg-icon');
const Tutorial = require('./tutorial');
const VersionInfo = require('./version-info');
/** /**
* @ngdoc directive * External link "tabs" inside of the help panel.
* @name helpPanel
* @description Displays product version and environment info
*/ */
// @ngInject function HelpPanelTab({ linkText, url }) {
module.exports = { return (
controllerAs: 'vm', <div className="help-panel-tabs__tab">
// @ngInject <a
controller: function($scope, $window, store, serviceUrl) { href={url}
this.userAgent = $window.navigator.userAgent; className="help-panel-tabs__link"
this.version = '__VERSION__'; // replaced by versionify target="_blank"
this.dateTime = new Date(); rel="noopener noreferrer"
this.serviceUrl = serviceUrl; >
{linkText}{' '}
$scope.$watch( <SvgIcon
function() { name="external"
return store.frames(); className="help-panel-tabs__icon"
}, inline={true}
function(frames) { />
if (frames.length === 0) { </a>
return; </div>
} );
this.url = frames[0].uri; }
this.documentFingerprint = frames[0].metadata.documentFingerprint;
}.bind(this) HelpPanelTab.propTypes = {
); /* What the tab's link should say */
}, linkText: propTypes.string.isRequired,
template: require('../templates/help-panel.html'), /* Where the tab's link should go */
bindings: { url: propTypes.string.isRequired,
auth: '<', };
onClose: '&',
}, /**
* A help sidebar panel with two sub-panels: tutorial and version info.
*/
function HelpPanel({ auth, session }) {
const mainFrame = useStore(store => store.mainFrame());
// Should this panel be auto-opened at app launch? Note that the actual
// auto-open triggering of this panel is owned by the `hypothesis-app` component.
// This reference is such that we know whether we should "dismiss" the tutorial
// (permanently for this user) when it is closed.
const hasAutoDisplayPreference = useStore(
store => !!store.getState().session.preferences.show_sidebar_tutorial
);
// The "Tutorial" (getting started) subpanel is the default panel shown
const [activeSubPanel, setActiveSubPanel] = useState('tutorial');
// Build version details about this session/app
const versionData = useMemo(() => {
const userInfo = auth || {};
const documentInfo = mainFrame || {};
return new VersionData(userInfo, documentInfo);
}, [auth, mainFrame]);
// The support ticket URL encodes some version info in it to pre-fill in the
// create-new-ticket form
const supportTicketURL = `https://web.hypothes.is/get-help/?sys_info=${versionData.asEncodedURLString()}`;
const subPanelTitles = {
tutorial: 'Getting started',
versionInfo: 'About this version',
};
const openSubPanel = (e, panelName) => {
e.preventDefault();
setActiveSubPanel(panelName);
};
const dismissFn = session.dismissSidebarTutorial; // Reference for useCallback dependency
const onActiveChanged = useCallback(
active => {
if (!active && hasAutoDisplayPreference) {
// If the tutorial is currently being auto-displayed, update the user
// preference to disable the auto-display from happening on subsequent
// app launches
dismissFn();
}
},
[dismissFn, hasAutoDisplayPreference]
);
return (
<SidebarPanel
title="Need some help?"
panelName={uiConstants.PANEL_HELP}
onActiveChanged={onActiveChanged}
>
<h3 className="help-panel__sub-panel-title">
{subPanelTitles[activeSubPanel]}
</h3>
<div className="help-panel__content">
{activeSubPanel === 'tutorial' && <Tutorial />}
{activeSubPanel === 'versionInfo' && (
<VersionInfo versionData={versionData} />
)}
<div className="help-panel__footer">
{activeSubPanel === 'versionInfo' && (
<a
href="#"
className="help-panel__sub-panel-link help-panel__sub-panel-link--left"
onClick={e => openSubPanel(e, 'tutorial')}
>
<SvgIcon name="arrow-left" className="help-panel__icon" />
<div>Getting started</div>
</a>
)}
{activeSubPanel === 'tutorial' && (
<a
href="#"
className="help-panel__sub-panel-link help-panel__sub-panel-link--right"
onClick={e => openSubPanel(e, 'versionInfo')}
>
<div>About this version</div>
<SvgIcon name="arrow-right" className="help-panel__icon" />
</a>
)}
</div>
</div>
<div className="help-panel-tabs">
<HelpPanelTab
linkText="Help topics"
url="https://web.hypothes.is/help/"
/>
<HelpPanelTab linkText="New support ticket" url={supportTicketURL} />
</div>
</SidebarPanel>
);
}
HelpPanel.propTypes = {
/* Object with auth and user information */
auth: propTypes.object.isRequired,
session: propTypes.object.isRequired,
}; };
HelpPanel.injectedProps = ['session'];
module.exports = withServices(HelpPanel);
...@@ -4,6 +4,9 @@ const events = require('../events'); ...@@ -4,6 +4,9 @@ const events = require('../events');
const { parseAccountID } = require('../util/account-id'); const { parseAccountID } = require('../util/account-id');
const serviceConfig = require('../service-config'); const serviceConfig = require('../service-config');
const bridgeEvents = require('../../shared/bridge-events'); const bridgeEvents = require('../../shared/bridge-events');
const uiConstants = require('../ui-constants');
const isSidebar = require('../util/is-sidebar');
const { shouldAutoDisplayTutorial } = require('../util/session-util');
/** /**
* Return the user's authentication status from their profile. * Return the user's authentication status from their profile.
...@@ -56,23 +59,34 @@ function HypothesisAppController( ...@@ -56,23 +59,34 @@ function HypothesisAppController(
// used by templates to show an intermediate or loading state. // used by templates to show an intermediate or loading state.
this.auth = { status: 'unknown' }; this.auth = { status: 'unknown' };
// App dialogs
this.helpPanel = { visible: false };
// Check to see if we're in the sidebar, or on a standalone page such as // Check to see if we're in the sidebar, or on a standalone page such as
// the stream page or an individual annotation page. // the stream page or an individual annotation page.
this.isSidebar = $window.top !== $window; this.isSidebar = isSidebar();
if (this.isSidebar) { if (this.isSidebar) {
frameSync.connect(); frameSync.connect();
} }
// Reload the view when the user switches accounts // Reload the view when the user switches accounts
this.onUserChange = profile => {
self.auth = authStateFromProfile(profile);
if (
shouldAutoDisplayTutorial(
this.isSidebar,
store.getState().session,
settings
)
) {
// Auto-open the tutorial (help) panel
store.openSidebarPanel(uiConstants.PANEL_HELP);
}
};
$scope.$on(events.USER_CHANGED, function(event, data) { $scope.$on(events.USER_CHANGED, function(event, data) {
self.auth = authStateFromProfile(data.profile); self.onUserChange(data.profile);
}); });
session.load().then(profile => { session.load().then(profile => {
self.auth = authStateFromProfile(profile); self.onUserChange(profile);
}); });
/** /**
...@@ -110,17 +124,6 @@ function HypothesisAppController( ...@@ -110,17 +124,6 @@ function HypothesisAppController(
$window.open(serviceUrl('signup')); $window.open(serviceUrl('signup'));
}; };
this.showHelpPanel = function() {
const service = serviceConfig(settings) || {};
if (service.onHelpRequestProvided) {
// Let the host page handle the help request.
bridge.call(bridgeEvents.HELP_REQUESTED);
return;
}
this.helpPanel.visible = true;
};
// Prompt to discard any unsaved drafts. // Prompt to discard any unsaved drafts.
const promptToLogout = function() { const promptToLogout = function() {
// TODO - Replace this with a UI which doesn't look terrible. // TODO - Replace this with a UI which doesn't look terrible.
......
'use strict'; 'use strict';
const angular = require('angular'); const { mount } = require('enzyme');
const { createElement } = require('preact');
const { act } = require('preact/test-utils');
describe('helpPanel', function() { const HelpPanel = require('../help-panel');
const mockImportedComponents = require('./mock-imported-components');
describe('HelpPanel', function() {
let fakeAuth;
let fakeSessionService;
let fakeStore; let fakeStore;
let $componentController; let fakeVersionData;
let $rootScope; let fakeVersionDataObject;
function createComponent(props) {
return mount(
<HelpPanel auth={fakeAuth} session={fakeSessionService} {...props} />
);
}
beforeEach(function() { beforeEach(() => {
fakeAuth = {};
fakeSessionService = { dismissSidebarTutorial: sinon.stub() };
fakeStore = { fakeStore = {
frames: sinon.stub().returns([]), getState: sinon
.stub()
.returns({ session: { preferences: { show_sidebar_tutorial: true } } }),
mainFrame: sinon.stub().returns(null),
};
fakeVersionDataObject = {
asEncodedURLString: sinon.stub().returns('fakeURLString'),
}; };
fakeVersionData = sinon.stub().returns(fakeVersionDataObject);
HelpPanel.$imports.$mock(mockImportedComponents());
HelpPanel.$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
'../util/version-data': fakeVersionData,
});
});
afterEach(() => {
HelpPanel.$imports.$restore();
});
context('when viewing tutorial sub-panel', () => {
it('should show tutorial by default', () => {
const wrapper = createComponent();
const subHeader = wrapper.find('.help-panel__sub-panel-title');
assert.equal(subHeader.text(), 'Getting started');
assert.isTrue(wrapper.find('Tutorial').exists());
assert.isFalse(wrapper.find('VersionInfo').exists());
});
it('should show navigation link to versionInfo sub-panel', () => {
const wrapper = createComponent();
const link = wrapper.find('.help-panel__sub-panel-link');
assert.equal(link.text(), 'About this version');
});
it('should switch to versionInfo sub-panel when footer link clicked', () => {
const wrapper = createComponent();
wrapper.find('.help-panel__sub-panel-link').simulate('click');
assert.equal(
wrapper.find('.help-panel__sub-panel-title').text(),
'About this version'
);
assert.isTrue(wrapper.find('VersionInfo').exists());
assert.equal(
wrapper.find('VersionInfo').prop('versionData'),
fakeVersionDataObject
);
assert.isFalse(wrapper.find('Tutorial').exists());
});
});
context('when viewing versionInfo sub-panel', () => {
it('should show navigation link back to tutorial sub-panel', () => {
const wrapper = createComponent();
wrapper.find('.help-panel__sub-panel-link').simulate('click');
const link = wrapper.find('.help-panel__sub-panel-link');
angular.module('h', []).component('helpPanel', require('../help-panel')); assert.isTrue(wrapper.find('VersionInfo').exists());
assert.isFalse(wrapper.find('Tutorial').exists());
assert.equal(link.text(), 'Getting started');
});
it('should switch to tutorial sub-panel when link clicked', () => {
const wrapper = createComponent();
// Click to get to version-info sub-panel...
wrapper.find('.help-panel__sub-panel-link').simulate('click');
const link = wrapper.find('.help-panel__sub-panel-link');
// Click again to get back to tutorial sub-panel
link.simulate('click');
assert.isFalse(wrapper.find('VersionInfo').exists());
assert.isTrue(wrapper.find('Tutorial').exists());
});
});
describe('`HelpPanelTab`s', () => {
it('should render static link to knowledge base', () => {
const wrapper = createComponent();
angular.mock.module('h', { assert.isTrue(
store: fakeStore, wrapper
serviceUrl: sinon.stub(), .find('HelpPanelTab')
.filter({ linkText: 'Help topics' })
.exists()
);
}); });
angular.mock.inject(function(_$componentController_, _$rootScope_) { it('should render dynamic link to create a new help ticket', () => {
$componentController = _$componentController_; const wrapper = createComponent();
$rootScope = _$rootScope_; const helpTab = wrapper
.find('HelpPanelTab')
.filter({ linkText: 'New support ticket' });
assert.isTrue(helpTab.exists());
assert.include(helpTab.prop('url'), 'fakeURLString');
}); });
}); });
it('displays the URL and fingerprint of the first connected frame', function() { context('dismissing the tutorial and clearing profile setting', () => {
fakeStore.frames.returns([ context('profile preference to auto-show tutorial is truthy', () => {
{ beforeEach(() => {
uri: 'https://publisher.org/article.pdf', fakeStore.getState.returns({
metadata: { session: { preferences: { show_sidebar_tutorial: true } },
documentFingerprint: '12345', });
}, });
},
]); it('should not dismiss the panel when it is initially opened', () => {
const wrapper = createComponent();
const $scope = $rootScope.$new(); const onActiveChanged = wrapper
const ctrl = $componentController('helpPanel', { $scope: $scope }); .find('SidebarPanel')
$scope.$digest(); .prop('onActiveChanged');
assert.equal(ctrl.url, 'https://publisher.org/article.pdf'); act(() => {
assert.equal(ctrl.documentFingerprint, '12345'); // "Activate" the panel (simulate the `SidebarPanel` communicating
// an active state via callback prop)
onActiveChanged(true);
});
assert.notOk(fakeSessionService.dismissSidebarTutorial.callCount);
});
it('should invoke dismiss service method when panel is first closed', () => {
const wrapper = createComponent();
const onActiveChanged = wrapper
.find('SidebarPanel')
.prop('onActiveChanged');
act(() => {
// "Activate" the panel (simulate the `SidebarPanel` communicating
// an active state via callback prop)
onActiveChanged(true);
// Now "close" the panel
onActiveChanged(false);
});
assert.calledOnce(fakeSessionService.dismissSidebarTutorial);
});
});
context('profile preference to auto-show tutorial is falsy', () => {
beforeEach(() => {
fakeStore.getState.returns({
session: { preferences: { show_sidebar_tutorial: false } },
});
});
it('should not invoke dismiss service method when panel is closed', () => {
const wrapper = createComponent();
const onActiveChanged = wrapper
.find('SidebarPanel')
.prop('onActiveChanged');
act(() => {
// "Activate" the panel (simulate the `SidebarPanel` communicating
// an active state via callback prop)
onActiveChanged(true);
// Now "close" the panel
onActiveChanged(false);
});
assert.notOk(fakeSessionService.dismissSidebarTutorial.callCount);
});
});
}); });
}); });
...@@ -18,9 +18,11 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -18,9 +18,11 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeFeatures = null; let fakeFeatures = null;
let fakeFlash = null; let fakeFlash = null;
let fakeFrameSync = null; let fakeFrameSync = null;
let fakeIsSidebar = null;
let fakeParams = null; let fakeParams = null;
let fakeServiceConfig = null; let fakeServiceConfig = null;
let fakeSession = null; let fakeSession = null;
let fakeShouldAutoDisplayTutorial = null;
let fakeGroups = null; let fakeGroups = null;
let fakeRoute = null; let fakeRoute = null;
let fakeServiceUrl = null; let fakeServiceUrl = null;
...@@ -40,10 +42,16 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -40,10 +42,16 @@ describe('sidebar.components.hypothesis-app', function() {
}); });
beforeEach(function() { beforeEach(function() {
fakeIsSidebar = sandbox.stub().returns(true);
fakeServiceConfig = sandbox.stub(); fakeServiceConfig = sandbox.stub();
fakeShouldAutoDisplayTutorial = sinon.stub().returns(false);
hypothesisApp.$imports.$mock({ hypothesisApp.$imports.$mock({
'../util/is-sidebar': fakeIsSidebar,
'../service-config': fakeServiceConfig, '../service-config': fakeServiceConfig,
'../util/session-util': {
shouldAutoDisplayTutorial: fakeShouldAutoDisplayTutorial,
},
}); });
angular.module('h', []).component('hypothesisApp', hypothesisApp); angular.module('h', []).component('hypothesisApp', hypothesisApp);
...@@ -60,8 +68,15 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -60,8 +68,15 @@ describe('sidebar.components.hypothesis-app', function() {
fakeStore = { fakeStore = {
tool: 'comment', tool: 'comment',
clearSelectedAnnotations: sandbox.spy(), clearSelectedAnnotations: sandbox.spy(),
getState: sinon.stub(), getState: sinon.stub().returns({
session: {
preferences: {
show_sidebar_tutorial: false,
},
},
}),
clearGroups: sinon.stub(), clearGroups: sinon.stub(),
openSidebarPanel: sinon.stub(),
// draft store // draft store
countDrafts: sandbox.stub().returns(0), countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(), discardAllDrafts: sandbox.stub(),
...@@ -143,32 +158,36 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -143,32 +158,36 @@ describe('sidebar.components.hypothesis-app', function() {
sandbox.restore(); sandbox.restore();
}); });
describe('#isSidebar', function() {
it('is false if the window is the top window', function() {
fakeWindow.top = fakeWindow;
const ctrl = createController();
assert.isFalse(ctrl.isSidebar);
});
it('is true if the window is not the top window', function() {
fakeWindow.top = {};
const ctrl = createController();
assert.isTrue(ctrl.isSidebar);
});
});
it('connects to host frame in the sidebar app', function() { it('connects to host frame in the sidebar app', function() {
fakeWindow.top = {}; fakeIsSidebar.returns(true);
createController(); createController();
assert.called(fakeFrameSync.connect); assert.called(fakeFrameSync.connect);
}); });
it('does not connect to the host frame in the stream', function() { it('does not connect to the host frame in the stream', function() {
fakeWindow.top = fakeWindow; fakeIsSidebar.returns(false);
createController(); createController();
assert.notCalled(fakeFrameSync.connect); assert.notCalled(fakeFrameSync.connect);
}); });
describe('auto-opening tutorial', () => {
it('should open tutorial on profile load when criteria are met', () => {
fakeShouldAutoDisplayTutorial.returns(true);
createController();
return fakeSession.load().then(() => {
assert.calledOnce(fakeStore.openSidebarPanel);
});
});
it('should not open tutorial on profile load when criteria are not met', () => {
fakeShouldAutoDisplayTutorial.returns(false);
createController();
return fakeSession.load().then(() => {
assert.equal(fakeStore.openSidebarPanel.callCount, 0);
});
});
});
it('auth.status is "unknown" on startup', function() { it('auth.status is "unknown" on startup', function() {
const ctrl = createController(); const ctrl = createController();
assert.equal(ctrl.auth.status, 'unknown'); assert.equal(ctrl.auth.status, 'unknown');
...@@ -290,66 +309,6 @@ describe('sidebar.components.hypothesis-app', function() { ...@@ -290,66 +309,6 @@ describe('sidebar.components.hypothesis-app', function() {
}); });
}); });
describe('#showHelpPanel', function() {
context('when using a third-party service', function() {
context("when there's no onHelpRequest callback function", function() {
beforeEach('configure a service with no onHelpRequest', function() {
fakeServiceConfig.returns({});
});
it('does not send an event', function() {
createController().showHelpPanel();
assert.notCalled(fakeBridge.call);
});
it('shows the help panel', function() {
const ctrl = createController();
ctrl.showHelpPanel();
assert.isTrue(ctrl.helpPanel.visible);
});
});
context("when there's an onHelpRequest callback function", function() {
beforeEach('provide an onHelpRequest callback', function() {
fakeServiceConfig.returns({ onHelpRequestProvided: true });
});
it('sends the HELP_REQUESTED event', function() {
createController().showHelpPanel();
assert.calledWith(fakeBridge.call, bridgeEvents.HELP_REQUESTED);
});
it('does not show the help panel', function() {
const ctrl = createController();
ctrl.showHelpPanel();
assert.isFalse(ctrl.helpPanel.visible);
});
});
});
context('when not using a third-party service', function() {
it('does not send an event', function() {
createController().showHelpPanel();
assert.notCalled(fakeBridge.call);
});
it('shows the help panel', function() {
const ctrl = createController();
ctrl.showHelpPanel();
assert.isTrue(ctrl.helpPanel.visible);
});
});
});
describe('#login()', function() { describe('#login()', function() {
beforeEach(() => { beforeEach(() => {
fakeAuth.login = sinon.stub().returns(Promise.resolve()); fakeAuth.login = sinon.stub().returns(Promise.resolve());
......
...@@ -4,15 +4,18 @@ const { createElement } = require('preact'); ...@@ -4,15 +4,18 @@ const { createElement } = require('preact');
const { mount } = require('enzyme'); const { mount } = require('enzyme');
const uiConstants = require('../../ui-constants'); const uiConstants = require('../../ui-constants');
const bridgeEvents = require('../../../shared/bridge-events');
const TopBar = require('../top-bar'); const TopBar = require('../top-bar');
const mockImportedComponents = require('./mock-imported-components'); const mockImportedComponents = require('./mock-imported-components');
describe('TopBar', () => { describe('TopBar', () => {
const fakeSettings = {}; const fakeSettings = {};
let fakeBridge;
let fakeStore; let fakeStore;
let fakeStreamer; let fakeStreamer;
let fakeIsThirdPartyService; let fakeIsThirdPartyService;
let fakeServiceConfig;
beforeEach(() => { beforeEach(() => {
fakeIsThirdPartyService = sinon.stub().returns(false); fakeIsThirdPartyService = sinon.stub().returns(false);
...@@ -29,6 +32,12 @@ describe('TopBar', () => { ...@@ -29,6 +32,12 @@ describe('TopBar', () => {
toggleSidebarPanel: sinon.stub(), toggleSidebarPanel: sinon.stub(),
}; };
fakeBridge = {
call: sinon.stub(),
};
fakeServiceConfig = sinon.stub().returns({});
fakeStreamer = { fakeStreamer = {
applyPendingUpdates: sinon.stub(), applyPendingUpdates: sinon.stub(),
}; };
...@@ -37,6 +46,7 @@ describe('TopBar', () => { ...@@ -37,6 +46,7 @@ describe('TopBar', () => {
TopBar.$imports.$mock({ TopBar.$imports.$mock({
'../store/use-store': callback => callback(fakeStore), '../store/use-store': callback => callback(fakeStore),
'../util/is-third-party-service': fakeIsThirdPartyService, '../util/is-third-party-service': fakeIsThirdPartyService,
'../service-config': fakeServiceConfig,
}); });
}); });
...@@ -57,6 +67,7 @@ describe('TopBar', () => { ...@@ -57,6 +67,7 @@ describe('TopBar', () => {
return mount( return mount(
<TopBar <TopBar
auth={auth} auth={auth}
bridge={fakeBridge}
isSidebar={true} isSidebar={true}
settings={fakeSettings} settings={fakeSettings}
streamer={fakeStreamer} streamer={fakeStreamer}
...@@ -86,14 +97,41 @@ describe('TopBar', () => { ...@@ -86,14 +97,41 @@ describe('TopBar', () => {
assert.called(fakeStreamer.applyPendingUpdates); assert.called(fakeStreamer.applyPendingUpdates);
}); });
it('shows Help Panel when help icon is clicked', () => { describe('`HelpButton` and help requests', () => {
const onShowHelpPanel = sinon.stub(); context('no help service handler configured in services (default)', () => {
const wrapper = createTopBar({ it('toggles Help Panel on click', () => {
onShowHelpPanel: onShowHelpPanel, const wrapper = createTopBar();
const help = helpBtn(wrapper);
help.simulate('click');
assert.calledWith(fakeStore.toggleSidebarPanel, uiConstants.PANEL_HELP);
});
it('displays a help icon active state when help panel active', () => {
// state returning active sidebar panel as `PANEL_HELP` triggers active class
fakeStore.getState = sinon.stub().returns({
sidebarPanels: {
activePanelName: uiConstants.PANEL_HELP,
},
});
const wrapper = createTopBar();
const help = helpBtn(wrapper);
wrapper.update();
assert.isTrue(help.hasClass('top-bar__btn--active'));
assert.isOk(help.prop('aria-expanded'));
});
context('help service handler configured in services', () => {
it('fires a bridge event if help clicked and service is configured', () => {
fakeServiceConfig.returns({ onHelpRequestProvided: true });
const wrapper = createTopBar();
const help = helpBtn(wrapper);
help.simulate('click');
assert.equal(fakeStore.toggleSidebarPanel.callCount, 0);
assert.calledWith(fakeBridge.call, bridgeEvents.HELP_REQUESTED);
});
});
}); });
const help = helpBtn(wrapper);
help.simulate('click');
assert.called(onShowHelpPanel);
}); });
describe('login/account actions', () => { describe('login/account actions', () => {
...@@ -170,7 +208,10 @@ describe('TopBar', () => { ...@@ -170,7 +208,10 @@ describe('TopBar', () => {
it('toggles the share annotations panel when "Share" is clicked', () => { it('toggles the share annotations panel when "Share" is clicked', () => {
const wrapper = createTopBar(); const wrapper = createTopBar();
wrapper.find('[title="Share annotations on this page"]').simulate('click'); wrapper.find('[title="Share annotations on this page"]').simulate('click');
assert.called(fakeStore.toggleSidebarPanel); assert.calledWith(
fakeStore.toggleSidebarPanel,
uiConstants.PANEL_SHARE_ANNOTATIONS
);
}); });
it('adds an active-state class to the "Share" icon when the panel is open', () => { it('adds an active-state class to the "Share" icon when the panel is open', () => {
...@@ -217,14 +258,5 @@ describe('TopBar', () => { ...@@ -217,14 +258,5 @@ describe('TopBar', () => {
assert.isFalse(wrapper.exists('SortMenu')); assert.isFalse(wrapper.exists('SortMenu'));
assert.isFalse(wrapper.exists('button[title="Share this page"]')); assert.isFalse(wrapper.exists('button[title="Share this page"]'));
}); });
it('does show the Help menu and user menu', () => {
const wrapper = createTopBar({
isSidebar: false,
auth: { status: 'logged-in' },
});
assert.isTrue(wrapper.exists('button[title="Help"]'));
assert.isTrue(wrapper.exists('UserMenu'));
});
}); });
}); });
...@@ -4,9 +4,11 @@ const { Fragment, createElement } = require('preact'); ...@@ -4,9 +4,11 @@ const { Fragment, createElement } = require('preact');
const classnames = require('classnames'); const classnames = require('classnames');
const propTypes = require('prop-types'); const propTypes = require('prop-types');
const bridgeEvents = require('../../shared/bridge-events');
const useStore = require('../store/use-store'); const useStore = require('../store/use-store');
const { applyTheme } = require('../util/theme'); const { applyTheme } = require('../util/theme');
const isThirdPartyService = require('../util/is-third-party-service'); const isThirdPartyService = require('../util/is-third-party-service');
const serviceConfig = require('../service-config');
const { withServices } = require('../util/service-context'); const { withServices } = require('../util/service-context');
const uiConstants = require('../ui-constants'); const uiConstants = require('../ui-constants');
...@@ -17,16 +19,44 @@ const SortMenu = require('./sort-menu'); ...@@ -17,16 +19,44 @@ const SortMenu = require('./sort-menu');
const SvgIcon = require('./svg-icon'); const SvgIcon = require('./svg-icon');
const UserMenu = require('./user-menu'); const UserMenu = require('./user-menu');
/**
* Button for opening/closing the help panel
*/
function HelpButton({ onClick }) {
const isActive = useStore(
store =>
store.getState().sidebarPanels.activePanelName === uiConstants.PANEL_HELP
);
return (
<button
className={classnames('top-bar__btn top-bar__help-btn', {
'top-bar__btn--active': isActive,
})}
onClick={onClick}
title="Help"
aria-expanded={isActive}
aria-pressed={isActive}
>
<SvgIcon name="help" className="top-bar__help-icon" />
</button>
);
}
HelpButton.propTypes = {
/* callback */
onClick: propTypes.func.isRequired,
};
/** /**
* The toolbar which appears at the top of the sidebar providing actions * The toolbar which appears at the top of the sidebar providing actions
* to switch groups, view account information, sort/filter annotations etc. * to switch groups, view account information, sort/filter annotations etc.
*/ */
function TopBar({ function TopBar({
auth, auth,
bridge,
isSidebar, isSidebar,
onLogin, onLogin,
onLogout, onLogout,
onShowHelpPanel,
onSignUp, onSignUp,
settings, settings,
streamer, streamer,
...@@ -51,6 +81,19 @@ function TopBar({ ...@@ -51,6 +81,19 @@ function TopBar({
togglePanelFn(uiConstants.PANEL_SHARE_ANNOTATIONS); togglePanelFn(uiConstants.PANEL_SHARE_ANNOTATIONS);
}; };
/**
* Open the help panel, or, if a service callback is configured to handle
* help requests, fire a relevant event instead
*/
const requestHelp = () => {
const service = serviceConfig(settings) || {};
if (service.onHelpRequestProvided) {
bridge.call(bridgeEvents.HELP_REQUESTED);
} else {
togglePanelFn(uiConstants.PANEL_HELP);
}
};
const loginControl = ( const loginControl = (
<Fragment> <Fragment>
{auth.status === 'unknown' && ( {auth.status === 'unknown' && (
...@@ -82,14 +125,7 @@ function TopBar({ ...@@ -82,14 +125,7 @@ function TopBar({
<div className="top-bar__inner content"> <div className="top-bar__inner content">
<StreamSearchInput /> <StreamSearchInput />
<div className="top-bar__expander" /> <div className="top-bar__expander" />
<button <HelpButton onClick={requestHelp} />
className="top-bar__btn top-bar__help-btn"
onClick={onShowHelpPanel}
title="Help"
aria-label="Help"
>
<SvgIcon name="help" className="top-bar__help-icon" />
</button>
{loginControl} {loginControl}
</div> </div>
)} )}
...@@ -124,14 +160,7 @@ function TopBar({ ...@@ -124,14 +160,7 @@ function TopBar({
<SvgIcon name="share" /> <SvgIcon name="share" />
</button> </button>
)} )}
<button <HelpButton onClick={requestHelp} />
className="top-bar__btn top-bar__help-btn"
onClick={onShowHelpPanel}
title="Help"
aria-label="Help"
>
<SvgIcon name="help" className="top-bar__help-icon" />
</button>
{loginControl} {loginControl}
</div> </div>
)} )}
...@@ -152,16 +181,13 @@ TopBar.propTypes = { ...@@ -152,16 +181,13 @@ TopBar.propTypes = {
username: propTypes.string, username: propTypes.string,
}), }),
bridge: propTypes.object.isRequired,
/** /**
* Flag indicating whether the app is the sidebar or a top-level page. * Flag indicating whether the app is the sidebar or a top-level page.
*/ */
isSidebar: propTypes.bool, isSidebar: propTypes.bool,
/**
* Callback invoked when user clicks "Help" button.
*/
onShowHelpPanel: propTypes.func,
/** /**
* Callback invoked when user clicks "Login" button. * Callback invoked when user clicks "Login" button.
*/ */
...@@ -178,6 +204,6 @@ TopBar.propTypes = { ...@@ -178,6 +204,6 @@ TopBar.propTypes = {
streamer: propTypes.object, streamer: propTypes.object,
}; };
TopBar.injectedProps = ['settings', 'streamer']; TopBar.injectedProps = ['bridge', 'settings', 'streamer'];
module.exports = withServices(TopBar); module.exports = withServices(TopBar);
...@@ -155,6 +155,8 @@ function startAngularApp(config) { ...@@ -155,6 +155,8 @@ function startAngularApp(config) {
.component( .component(
'helpLink', 'helpLink',
wrapReactComponent(require('./components/help-link')) wrapReactComponent(require('./components/help-link'))
'helpPanel',
wrapReactComponent(require('./components/help-panel'))
) )
.component('helpPanel', require('./components/help-panel')) .component('helpPanel', require('./components/help-panel'))
.component( .component(
......
...@@ -4,16 +4,11 @@ ...@@ -4,16 +4,11 @@
on-login="vm.login()" on-login="vm.login()"
on-sign-up="vm.signUp()" on-sign-up="vm.signUp()"
on-logout="vm.logout()" on-logout="vm.logout()"
on-show-help-panel="vm.showHelpPanel()"
is-sidebar="::vm.isSidebar"> is-sidebar="::vm.isSidebar">
</top-bar> </top-bar>
<div class="content"> <div class="content">
<sidebar-tutorial ng-if="vm.isSidebar"></sidebar-tutorial> <help-panel auth="vm.auth"></help-panel>
<help-panel ng-if="vm.helpPanel.visible"
on-close="vm.helpPanel.visible = false"
auth="vm.auth">
</help-panel>
<share-annotations-panel></share-annotations-panel> <share-annotations-panel></share-annotations-panel>
<main ng-view=""></main> <main ng-view=""></main>
</div> </div>
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
*/ */
module.exports = { module.exports = {
PANEL_HELP: 'help',
PANEL_SHARE_ANNOTATIONS: 'shareGroupAnnotations', PANEL_SHARE_ANNOTATIONS: 'shareGroupAnnotations',
TAB_ANNOTATIONS: 'annotation', TAB_ANNOTATIONS: 'annotation',
TAB_NOTES: 'note', TAB_NOTES: 'note',
......
.help-panel { .help-panel {
@include font-normal; &__sub-panel-title {
background: $grey-3; margin: 0;
margin-bottom: 0.72em; padding: 0.5em;
padding: $layout-h-margin; text-align: center;
border-radius: 2px; font-size: 1.25em;
} font-weight: 600;
}
.help-panel-title { &__content {
color: $grey-6; padding: 0.5em;
font-weight: bold; border-top: 1px solid $grey-3;
// Margin between top of the dialog and border-bottom: 1px solid $grey-3;
// top of x-height of title should be ~15px. line-height: $normal-line-height;
margin-top: -5px; font-size: $normal-font-size;
} }
.help-panel-content { &__icon {
// Margin between bottom of ascent of title and width: 12px;
// top of x-height of content should be 20px. height: 12px;
margin-top: 11px; }
}
.help-panel-content__key { &__footer {
width: 100px; padding: 0.5em 0;
float: left; display: flex;
color: $grey-4; align-items: center;
} }
.help-panel-content__val { &__sub-panel-link {
word-wrap: break-word; display: flex;
margin-left: 100px; align-items: center;
} color: $brand;
.help-panel-content { &--right {
margin-top: 10px; margin-left: auto;
margin-bottom: 15px; }
}
&-icon {
margin: 5px;
width: 12px;
height: 12px;
}
}
&-tabs {
display: flex;
align-items: center;
&__tab {
flex: 1 1 0px;
margin-top: 0.5em;
border-right: 1px solid $grey-3;
text-align: center;
font-size: 1.25em;
color: $grey-5;
&:last-of-type {
border-right: none;
}
}
&__link {
color: $grey-5;
}
.help-panel-content__link { &__icon {
color: $grey-6; width: 12px;
text-decoration: underline; height: 12px;
&:hover { }
text-decoration: underline;
} }
} }
...@@ -15,6 +15,16 @@ ...@@ -15,6 +15,16 @@
border-bottom-style: solid; border-bottom-style: solid;
} }
&__subheader {
width: 100%;
text-align: center;
padding: 1em 0.5em;
border-bottom: 1px solid $grey-3;
font-size: 1.25em;
font-weight: 500;
color: $grey-5;
}
&__title { &__title {
color: $brand; color: $brand;
font-size: $body2-font-size; font-size: $body2-font-size;
...@@ -44,7 +54,7 @@ ...@@ -44,7 +54,7 @@
} }
&__content { &__content {
padding: 0.75em; margin: 1em;
padding-top: 0; margin-top: 0;
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment