Unverified Commit ee714018 authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1476 from hypothesis/help-panel

Replace Help and Tutorial panels with single, preact panel
parents 518578a9 c793ad9b
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
/**
* Render an HTML link for sending an email to Hypothes.is support. This link
* pre-populates the email body with various details about the app's state.
*/
function HelpLink({
auth,
dateTime,
documentFingerprint = '-',
url,
userAgent,
version,
}) {
const toAddress = 'support@hypothes.is';
const subject = encodeURIComponent('Hypothesis Support');
const username = auth.username ? auth.username : '-';
// URL-encode informational key-value pairs for the email's body content
const bodyAttrs = [
`Version: ${version}`,
`User Agent: ${userAgent}`,
`URL: ${url}`,
`PDF Fingerprint: ${documentFingerprint}`,
`Date: ${dateTime}`,
`Username: ${username}`,
].map(x => encodeURIComponent(x));
// Create a pre-populated email body with each key-value pair on its own line
const body = bodyAttrs.join(encodeURIComponent('\r\n'));
const href = `mailto:${toAddress}?subject=${subject}&body=${body}`;
return (
<a className="help-panel-content__link" href={href}>
Send us an email
</a>
);
}
HelpLink.propTypes = {
auth: propTypes.object.isRequired,
dateTime: propTypes.object.isRequired,
documentFingerprint: propTypes.string,
url: propTypes.string,
userAgent: propTypes.string.isRequired,
version: propTypes.string.isRequired,
};
module.exports = HelpLink;
'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
* @name helpPanel
* @description Displays product version and environment info
* External link "tabs" inside of the help panel.
*/
// @ngInject
module.exports = {
controllerAs: 'vm',
// @ngInject
controller: function($scope, $window, store, serviceUrl) {
this.userAgent = $window.navigator.userAgent;
this.version = '__VERSION__'; // replaced by versionify
this.dateTime = new Date();
this.serviceUrl = serviceUrl;
$scope.$watch(
function() {
return store.frames();
},
function(frames) {
if (frames.length === 0) {
return;
}
this.url = frames[0].uri;
this.documentFingerprint = frames[0].metadata.documentFingerprint;
}.bind(this)
function HelpPanelTab({ linkText, url }) {
return (
<div className="help-panel-tabs__tab">
<a
href={url}
className="help-panel-tabs__link"
target="_blank"
rel="noopener noreferrer"
>
{linkText}{' '}
<SvgIcon
name="external"
className="help-panel-tabs__icon"
inline={true}
/>
</a>
</div>
);
}
HelpPanelTab.propTypes = {
/* What the tab's link should say */
linkText: propTypes.string.isRequired,
/* Where the tab's link should go */
url: propTypes.string.isRequired,
};
/**
* 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();
}
},
template: require('../templates/help-panel.html'),
bindings: {
auth: '<',
onClose: '&',
},
[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');
const { parseAccountID } = require('../util/account-id');
const serviceConfig = require('../service-config');
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.
......@@ -56,23 +59,34 @@ function HypothesisAppController(
// used by templates to show an intermediate or loading state.
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
// the stream page or an individual annotation page.
this.isSidebar = $window.top !== $window;
this.isSidebar = isSidebar();
if (this.isSidebar) {
frameSync.connect();
}
// 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) {
self.auth = authStateFromProfile(data.profile);
self.onUserChange(data.profile);
});
session.load().then(profile => {
self.auth = authStateFromProfile(profile);
self.onUserChange(profile);
});
/**
......@@ -110,17 +124,6 @@ function HypothesisAppController(
$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.
const promptToLogout = function() {
// TODO - Replace this with a UI which doesn't look terrible.
......
'use strict';
const sessionUtil = require('../util/session-util');
const isThirdPartyService = require('../util/is-third-party-service');
// @ngInject
function SidebarTutorialController(session, settings) {
// Compute once since this doesn't change after the app starts.
const isThirdPartyService_ = isThirdPartyService(settings);
this.isThemeClean = settings.theme === 'clean';
this.showSidebarTutorial = function() {
return sessionUtil.shouldShowSidebarTutorial(session.state);
};
this.dismiss = function() {
session.dismissSidebarTutorial();
};
this.canCreatePrivateGroup = () => {
// Private group creation in the client is limited to first party users.
// In future we may extend this to third party users, but still disable
// private group creation in certain contexts (eg. the LMS app).
return !isThirdPartyService_;
};
this.canSharePage = () => {
// The "Share document" icon in the toolbar is limited to first party users.
// In future we may extend this to third party users, but still disable it
// in certain contexts (eg. the LMS app).
return !isThirdPartyService_;
};
}
/**
* @name sidebarTutorial
* @description Displays a short tutorial in the sidebar.
*/
// @ngInject
module.exports = {
controller: SidebarTutorialController,
controllerAs: 'vm',
bindings: {},
template: require('../templates/sidebar-tutorial.html'),
};
'use strict';
const { createElement } = require('preact');
const { mount } = require('enzyme');
const HelpLink = require('../help-link');
const mockImportedComponents = require('./mock-imported-components');
describe('Help (mailto) Link', () => {
let fakeAuth;
let fakeDateTime;
let fakeDocumentFingerprint;
let fakeUrl;
let fakeUserAgent;
let fakeVersion;
const createHelpLink = () => {
return mount(
<HelpLink
auth={fakeAuth}
dateTime={fakeDateTime}
documentFingerprint={fakeDocumentFingerprint}
url={fakeUrl}
userAgent={fakeUserAgent}
version={fakeVersion}
/>
);
};
beforeEach(() => {
fakeAuth = {
username: 'fiona.user',
};
fakeDateTime = new Date();
fakeDocumentFingerprint = 'fingerprint';
fakeUrl = 'http://www.example.com';
fakeUserAgent = 'Some User Agent';
fakeVersion = '1.0.0';
HelpLink.$imports.$mock(mockImportedComponents());
});
afterEach(() => {
HelpLink.$imports.$restore();
});
it('sets required props as part of formatted email body', () => {
const wrapper = createHelpLink();
const href = wrapper.find('a').prop('href');
[
{ label: 'Version', value: fakeVersion },
{ label: 'User Agent', value: fakeUserAgent },
{ label: 'URL', value: fakeUrl },
{ label: 'PDF Fingerprint', value: fakeDocumentFingerprint },
{ label: 'Date', value: fakeDateTime },
{ label: 'Username', value: fakeAuth.username },
].forEach(helpInfo => {
const emailBodyPart = encodeURIComponent(
`${helpInfo.label}: ${helpInfo.value}`
);
assert.include(href, emailBodyPart);
});
});
it('sets a default value for PDF document fingerprint if not provided', () => {
fakeDocumentFingerprint = undefined;
const wrapper = createHelpLink();
const href = wrapper.find('a').prop('href');
const emailBodyPart = encodeURIComponent('PDF Fingerprint: -');
assert.include(href, emailBodyPart);
});
it('sets a default value for username if no authenticated user', () => {
fakeAuth = {};
const wrapper = createHelpLink();
const href = wrapper.find('a').prop('href');
const emailBodyPart = encodeURIComponent('Username: -');
assert.include(href, emailBodyPart);
});
});
'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 $componentController;
let $rootScope;
let fakeVersionData;
let fakeVersionDataObject;
function createComponent(props) {
return mount(
<HelpPanel auth={fakeAuth} session={fakeSessionService} {...props} />
);
}
beforeEach(function() {
beforeEach(() => {
fakeAuth = {};
fakeSessionService = { dismissSidebarTutorial: sinon.stub() };
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');
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');
angular.module('h', []).component('helpPanel', require('../help-panel'));
const link = wrapper.find('.help-panel__sub-panel-link');
// Click again to get back to tutorial sub-panel
link.simulate('click');
angular.mock.module('h', {
store: fakeStore,
serviceUrl: sinon.stub(),
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();
assert.isTrue(
wrapper
.find('HelpPanelTab')
.filter({ linkText: 'Help topics' })
.exists()
);
});
it('should render dynamic link to create a new help ticket', () => {
const wrapper = createComponent();
const helpTab = wrapper
.find('HelpPanelTab')
.filter({ linkText: 'New support ticket' });
assert.isTrue(helpTab.exists());
assert.include(helpTab.prop('url'), 'fakeURLString');
});
});
context('dismissing the tutorial and clearing profile setting', () => {
context('profile preference to auto-show tutorial is truthy', () => {
beforeEach(() => {
fakeStore.getState.returns({
session: { preferences: { show_sidebar_tutorial: true } },
});
});
it('should not dismiss the panel when it is initially opened', () => {
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);
});
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);
});
angular.mock.inject(function(_$componentController_, _$rootScope_) {
$componentController = _$componentController_;
$rootScope = _$rootScope_;
assert.calledOnce(fakeSessionService.dismissSidebarTutorial);
});
});
it('displays the URL and fingerprint of the first connected frame', function() {
fakeStore.frames.returns([
{
uri: 'https://publisher.org/article.pdf',
metadata: {
documentFingerprint: '12345',
},
},
]);
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');
const $scope = $rootScope.$new();
const ctrl = $componentController('helpPanel', { $scope: $scope });
$scope.$digest();
act(() => {
// "Activate" the panel (simulate the `SidebarPanel` communicating
// an active state via callback prop)
onActiveChanged(true);
// Now "close" the panel
onActiveChanged(false);
});
assert.equal(ctrl.url, 'https://publisher.org/article.pdf');
assert.equal(ctrl.documentFingerprint, '12345');
assert.notOk(fakeSessionService.dismissSidebarTutorial.callCount);
});
});
});
});
......@@ -18,9 +18,11 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeFeatures = null;
let fakeFlash = null;
let fakeFrameSync = null;
let fakeIsSidebar = null;
let fakeParams = null;
let fakeServiceConfig = null;
let fakeSession = null;
let fakeShouldAutoDisplayTutorial = null;
let fakeGroups = null;
let fakeRoute = null;
let fakeServiceUrl = null;
......@@ -40,10 +42,16 @@ describe('sidebar.components.hypothesis-app', function() {
});
beforeEach(function() {
fakeIsSidebar = sandbox.stub().returns(true);
fakeServiceConfig = sandbox.stub();
fakeShouldAutoDisplayTutorial = sinon.stub().returns(false);
hypothesisApp.$imports.$mock({
'../util/is-sidebar': fakeIsSidebar,
'../service-config': fakeServiceConfig,
'../util/session-util': {
shouldAutoDisplayTutorial: fakeShouldAutoDisplayTutorial,
},
});
angular.module('h', []).component('hypothesisApp', hypothesisApp);
......@@ -60,8 +68,15 @@ describe('sidebar.components.hypothesis-app', function() {
fakeStore = {
tool: 'comment',
clearSelectedAnnotations: sandbox.spy(),
getState: sinon.stub(),
getState: sinon.stub().returns({
session: {
preferences: {
show_sidebar_tutorial: false,
},
},
}),
clearGroups: sinon.stub(),
openSidebarPanel: sinon.stub(),
// draft store
countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(),
......@@ -143,32 +158,36 @@ describe('sidebar.components.hypothesis-app', function() {
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() {
fakeWindow.top = {};
fakeIsSidebar.returns(true);
createController();
assert.called(fakeFrameSync.connect);
});
it('does not connect to the host frame in the stream', function() {
fakeWindow.top = fakeWindow;
fakeIsSidebar.returns(false);
createController();
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() {
const ctrl = createController();
assert.equal(ctrl.auth.status, 'unknown');
......@@ -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() {
beforeEach(() => {
fakeAuth.login = sinon.stub().returns(Promise.resolve());
......
'use strict';
const Controller = require('../sidebar-tutorial').controller;
describe('sidebar/components/sidebar-tutorial', function() {
const defaultSession = { state: { preferences: {} } };
const firstPartySettings = {};
const thirdPartySettings = {
services: [
{
authority: 'publisher.org',
},
],
};
describe('#showSidebarTutorial', function() {
const settings = {};
it('returns true if show_sidebar_tutorial is true', function() {
const session = {
state: {
preferences: {
show_sidebar_tutorial: true,
},
},
};
const controller = new Controller(session, settings);
const result = controller.showSidebarTutorial();
assert.equal(result, true);
});
it('returns false if show_sidebar_tutorial is false', function() {
const session = {
state: {
preferences: {
show_sidebar_tutorial: false,
},
},
};
const controller = new Controller(session, settings);
const result = controller.showSidebarTutorial();
assert.equal(result, false);
});
it('returns false if show_sidebar_tutorial is missing', function() {
const session = { state: { preferences: {} } };
const controller = new Controller(session, settings);
const result = controller.showSidebarTutorial();
assert.equal(result, false);
});
});
describe('#canSharePage', () => {
it('is true for first party users', () => {
const controller = new Controller(defaultSession, firstPartySettings);
assert.isTrue(controller.canSharePage());
});
it('is false for third party users', () => {
const controller = new Controller(defaultSession, thirdPartySettings);
assert.isFalse(controller.canSharePage());
});
});
describe('#canCreatePrivateGroup', () => {
it('is true for first party users', () => {
const controller = new Controller(defaultSession, firstPartySettings);
assert.isTrue(controller.canSharePage());
});
it('is false for third party users', () => {
const controller = new Controller(defaultSession, thirdPartySettings);
assert.isFalse(controller.canSharePage());
});
});
});
......@@ -4,15 +4,18 @@ const { createElement } = require('preact');
const { mount } = require('enzyme');
const uiConstants = require('../../ui-constants');
const bridgeEvents = require('../../../shared/bridge-events');
const TopBar = require('../top-bar');
const mockImportedComponents = require('./mock-imported-components');
describe('TopBar', () => {
const fakeSettings = {};
let fakeBridge;
let fakeStore;
let fakeStreamer;
let fakeIsThirdPartyService;
let fakeServiceConfig;
beforeEach(() => {
fakeIsThirdPartyService = sinon.stub().returns(false);
......@@ -29,6 +32,12 @@ describe('TopBar', () => {
toggleSidebarPanel: sinon.stub(),
};
fakeBridge = {
call: sinon.stub(),
};
fakeServiceConfig = sinon.stub().returns({});
fakeStreamer = {
applyPendingUpdates: sinon.stub(),
};
......@@ -37,6 +46,7 @@ describe('TopBar', () => {
TopBar.$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
'../util/is-third-party-service': fakeIsThirdPartyService,
'../service-config': fakeServiceConfig,
});
});
......@@ -57,6 +67,7 @@ describe('TopBar', () => {
return mount(
<TopBar
auth={auth}
bridge={fakeBridge}
isSidebar={true}
settings={fakeSettings}
streamer={fakeStreamer}
......@@ -86,14 +97,41 @@ describe('TopBar', () => {
assert.called(fakeStreamer.applyPendingUpdates);
});
it('shows Help Panel when help icon is clicked', () => {
const onShowHelpPanel = sinon.stub();
const wrapper = createTopBar({
onShowHelpPanel: onShowHelpPanel,
describe('`HelpButton` and help requests', () => {
context('no help service handler configured in services (default)', () => {
it('toggles Help Panel on click', () => {
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.called(onShowHelpPanel);
assert.equal(fakeStore.toggleSidebarPanel.callCount, 0);
assert.calledWith(fakeBridge.call, bridgeEvents.HELP_REQUESTED);
});
});
});
});
describe('login/account actions', () => {
......@@ -170,7 +208,10 @@ describe('TopBar', () => {
it('toggles the share annotations panel when "Share" is clicked', () => {
const wrapper = createTopBar();
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', () => {
......@@ -217,14 +258,5 @@ describe('TopBar', () => {
assert.isFalse(wrapper.exists('SortMenu'));
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'));
});
});
});
'use strict';
const { mount } = require('enzyme');
const { createElement } = require('preact');
const Tutorial = require('../tutorial');
const mockImportedComponents = require('./mock-imported-components');
describe('Tutorial', function() {
let fakeIsThirdPartyService;
function createComponent(props) {
return mount(<Tutorial settings={{}} {...props} />);
}
beforeEach(() => {
fakeIsThirdPartyService = sinon.stub().returns(false);
Tutorial.$imports.$mock(mockImportedComponents());
Tutorial.$imports.$mock({
'../util/is-third-party-service': fakeIsThirdPartyService,
});
});
afterEach(() => {
Tutorial.$imports.$restore();
});
it('should show four "steps" of instructions to first-party users', () => {
const wrapper = createComponent();
const tutorialEntries = wrapper.find('li');
assert.equal(tutorialEntries.length, 4);
});
it('should show three "steps" of instructions to third-party users', () => {
fakeIsThirdPartyService.returns(true);
const wrapper = createComponent();
const tutorialEntries = wrapper.find('li');
assert.equal(tutorialEntries.length, 3);
});
[
{ iconName: 'annotate', commandName: 'Annotate' },
{ iconName: 'highlight', commandName: 'Highlight' },
{ iconName: 'reply', commandName: 'Reply' },
].forEach(testCase => {
it(`renders expected ${testCase.commandName} TutorialInstruction`, () => {
const wrapper = createComponent();
const instruction = wrapper.find('TutorialInstruction').filter({
iconName: testCase.iconName,
commandName: testCase.commandName,
});
assert.isTrue(instruction.exists());
});
});
});
'use strict';
const { mount } = require('enzyme');
const { createElement } = require('preact');
const VersionInfo = require('../version-info');
describe('VersionInfo', function() {
let fakeVersionData;
function createComponent(props) {
return mount(<VersionInfo versionData={fakeVersionData} {...props} />);
}
beforeEach(() => {
fakeVersionData = {
version: 'fakeVersion',
userAgent: 'fakeUserAgent',
url: 'fakeUrl',
fingerprint: 'fakeFingerprint',
account: 'fakeAccount',
timestamp: 'fakeTimestamp',
};
});
it('renders `versionData` information', () => {
const wrapper = createComponent();
const componentText = wrapper.text();
assert.include(componentText, 'fakeVersion');
assert.include(componentText, 'fakeUserAgent');
assert.include(componentText, 'fakeUrl');
assert.include(componentText, 'fakeFingerprint');
assert.include(componentText, 'fakeAccount');
assert.include(componentText, 'fakeTimestamp');
});
});
......@@ -4,9 +4,11 @@ const { Fragment, createElement } = require('preact');
const classnames = require('classnames');
const propTypes = require('prop-types');
const bridgeEvents = require('../../shared/bridge-events');
const useStore = require('../store/use-store');
const { applyTheme } = require('../util/theme');
const isThirdPartyService = require('../util/is-third-party-service');
const serviceConfig = require('../service-config');
const { withServices } = require('../util/service-context');
const uiConstants = require('../ui-constants');
......@@ -17,16 +19,44 @@ const SortMenu = require('./sort-menu');
const SvgIcon = require('./svg-icon');
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
* to switch groups, view account information, sort/filter annotations etc.
*/
function TopBar({
auth,
bridge,
isSidebar,
onLogin,
onLogout,
onShowHelpPanel,
onSignUp,
settings,
streamer,
......@@ -51,6 +81,19 @@ function TopBar({
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 = (
<Fragment>
{auth.status === 'unknown' && (
......@@ -82,14 +125,7 @@ function TopBar({
<div className="top-bar__inner content">
<StreamSearchInput />
<div className="top-bar__expander" />
<button
className="top-bar__btn top-bar__help-btn"
onClick={onShowHelpPanel}
title="Help"
aria-label="Help"
>
<SvgIcon name="help" className="top-bar__help-icon" />
</button>
<HelpButton onClick={requestHelp} />
{loginControl}
</div>
)}
......@@ -124,14 +160,7 @@ function TopBar({
<SvgIcon name="share" />
</button>
)}
<button
className="top-bar__btn top-bar__help-btn"
onClick={onShowHelpPanel}
title="Help"
aria-label="Help"
>
<SvgIcon name="help" className="top-bar__help-icon" />
</button>
<HelpButton onClick={requestHelp} />
{loginControl}
</div>
)}
......@@ -152,16 +181,13 @@ TopBar.propTypes = {
username: propTypes.string,
}),
bridge: propTypes.object.isRequired,
/**
* Flag indicating whether the app is the sidebar or a top-level page.
*/
isSidebar: propTypes.bool,
/**
* Callback invoked when user clicks "Help" button.
*/
onShowHelpPanel: propTypes.func,
/**
* Callback invoked when user clicks "Login" button.
*/
......@@ -178,6 +204,6 @@ TopBar.propTypes = {
streamer: propTypes.object,
};
TopBar.injectedProps = ['settings', 'streamer'];
TopBar.injectedProps = ['bridge', 'settings', 'streamer'];
module.exports = withServices(TopBar);
'use strict';
const { createElement } = require('preact');
const propTypes = require('prop-types');
const { withServices } = require('../util/service-context');
const isThirdPartyService = require('../util/is-third-party-service');
const SvgIcon = require('./svg-icon');
/**
* Subcomponent: an "instruction" within the tutorial step that includes an
* icon and a "command" associated with that icon. Encapsulating these together
* allows for styling to keep them from having a line break between them.
*/
function TutorialInstruction({ commandName, iconName }) {
return (
<span className="tutorial__instruction">
<SvgIcon name={iconName} inline={true} className="tutorial__icon" />
<em>{commandName}</em>
</span>
);
}
TutorialInstruction.propTypes = {
/* the name of the "command" the instruction represents, e.g. "Annotate" */
commandName: propTypes.string.isRequired,
/* the name of the SVGIcon to display with this instruction */
iconName: propTypes.string.isRequired,
};
/**
* Tutorial for using the sidebar app
*/
function Tutorial({ settings }) {
const canCreatePrivateGroups = !isThirdPartyService(settings);
return (
<ol className="tutorial__list">
<li className="tutorial__item">
To create an annotation, select text and click the{' '}
<TutorialInstruction iconName="annotate" commandName="Annotate" />{' '}
button.
</li>
<li className="tutorial__item">
To create a highlight (
<a
href="https://web.hypothes.is/help/why-are-highlights-private-by-default/"
target="_blank"
rel="noopener noreferrer"
>
visible only to you
</a>
), select text and click the{' '}
<TutorialInstruction iconName="highlight" commandName="Highlight" />{' '}
button.
</li>
{canCreatePrivateGroups && (
<li className="tutorial__item">
To annotate in a private group, select the group from the groups
dropdown. Don&apos;t see your group? Ask the group creator to send a{' '}
<a
href="https://web.hypothes.is/help/how-to-join-a-private-group/"
target="_blank"
rel="noopener noreferrer"
>
join link
</a>
).
</li>
)}
<li className="tutorial__item">
To reply to an annotation, click the{' '}
<TutorialInstruction iconName="reply" commandName="Reply" /> button.
</li>
</ol>
);
}
Tutorial.propTypes = {
settings: propTypes.object.isRequired,
};
Tutorial.injectedProps = ['settings'];
module.exports = withServices(Tutorial);
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
/**
* Display current client version info
*/
function VersionInfo({ versionData }) {
return (
<dl className="version-info">
<dt className="version-info__key">Version</dt>
<dd className="version-info__value">{versionData.version}</dd>
<dt className="version-info__key">User Agent</dt>
<dd className="version-info__value">{versionData.userAgent}</dd>
<dt className="version-info__key">URL</dt>
<dd className="version-info__value">{versionData.url}</dd>
<dt className="version-info__key">Fingerprint</dt>
<dd className="version-info__value">{versionData.fingerprint}</dd>
<dt className="version-info__key">Account</dt>
<dd className="version-info__value">{versionData.account}</dd>
<dt className="version-info__key">Date</dt>
<dd className="version-info__value">{versionData.timestamp}</dd>
</dl>
);
}
VersionInfo.propTypes = {
/**
* Object with version information
*
* @type {import('../util/version-info').VersionData}
*/
versionData: propTypes.object.isRequired,
};
module.exports = VersionInfo;
......@@ -153,10 +153,9 @@ function startAngularApp(config) {
)
.component('excerpt', require('./components/excerpt'))
.component(
'helpLink',
wrapReactComponent(require('./components/help-link'))
'helpPanel',
wrapReactComponent(require('./components/help-panel'))
)
.component('helpPanel', require('./components/help-panel'))
.component(
'loggedOutMessage',
wrapReactComponent(require('./components/logged-out-message'))
......@@ -194,7 +193,6 @@ function startAngularApp(config) {
'shareAnnotationsPanel',
wrapReactComponent(require('./components/share-annotations-panel'))
)
.component('sidebarTutorial', require('./components/sidebar-tutorial'))
.component('streamContent', require('./components/stream-content'))
.component('svgIcon', wrapReactComponent(require('./components/svg-icon')))
.component('tagEditor', require('./components/tag-editor'))
......
<div class="help-panel">
<i class="close h-icon-close"
role="button"
title="Close"
ng-click="vm.onClose()"></i>
<header class="help-panel-title">
Help
</header>
<div class="help-panel-content">
<help-link
version="vm.version"
user-agent="vm.userAgent"
url="vm.url"
document-fingerprint="vm.documentFingerprint"
auth="vm.auth"
date-time="vm.dateTime">
</help-link> if you have any questions or want to give us feedback.
You can also send <a class="help-panel-content__link" href="https://web.hypothes.is/get-help/" target="_blank">a support ticket</a>
or visit our <a class="help-panel-content__link" href="https://web.hypothes.is/help/" target="_blank"> help documents</a>.
</div>
<header class="help-panel-title">
About this version
</header>
<dl class="help-panel-content">
<dt class="help-panel-content__key">Version: </dt>
<dd class="help-panel-content__val">{{ vm.version }}</dd>
<dt class="help-panel-content__key">User agent: </dt>
<dd class="help-panel-content__val">{{ vm.userAgent }}</dd>
<div ng-if="vm.url">
<dt class="help-panel-content__key">URL: </dt>
<dd class="help-panel-content__val">{{ vm.url }}</dd>
</div>
<div ng-if="vm.documentFingerprint">
<dt class="help-panel-content__key">PDF fingerprint: </dt>
<dd class="help-panel-content__val">{{ vm.documentFingerprint }}</dd>
</div>
<div ng-if="vm.auth.userid">
<dt class="help-panel-content__key">Username: </dt>
<dd class="help-panel-content__val">{{ vm.auth.username }}</dd>
</div>
<dt class="help-panel-content__key">Date: </dt>
<dd class="help-panel-content__val">{{ vm.dateTime | date:'dd MMM yyyy HH:mm:ss Z' }}</dd>
</div>
</div>
......@@ -4,16 +4,11 @@
on-login="vm.login()"
on-sign-up="vm.signUp()"
on-logout="vm.logout()"
on-show-help-panel="vm.showHelpPanel()"
is-sidebar="::vm.isSidebar">
</top-bar>
<div class="content">
<sidebar-tutorial ng-if="vm.isSidebar"></sidebar-tutorial>
<help-panel ng-if="vm.helpPanel.visible"
on-close="vm.helpPanel.visible = false"
auth="vm.auth">
</help-panel>
<help-panel auth="vm.auth"></help-panel>
<share-annotations-panel></share-annotations-panel>
<main ng-view=""></main>
</div>
......
<div class="sheet" ng-if="vm.showSidebarTutorial() && !vm.isThemeClean">
<i class="close h-icon-close" role="button" title="Close"
ng-click="vm.dismiss()"></i>
<h1 class="sidebar-tutorial__header">How to get started</h1>
<ol class="sidebar-tutorial__list">
<li class="sidebar-tutorial__list-item">
<p class="sidebar-tutorial__list-item-content">
To create an annotation, select text and click the
<i class="h-icon-annotate"></i>&nbsp;button.
</p>
</li>
<li class="sidebar-tutorial__list-item">
<p class="sidebar-tutorial__list-item-content">
To add a note to the page you are viewing, click the
<i class="h-icon-note"></i>&nbsp;button.
</p>
</li>
<li class="sidebar-tutorial__list-item">
<p class="sidebar-tutorial__list-item-content">
To create a highlight, select text and click the
<i class="h-icon-highlight"></i>&nbsp;button.
</p>
</li>
<li class="sidebar-tutorial__list-item">
<p class="sidebar-tutorial__list-item-content">
To reply to an annotation, click the
<i class="h-icon-annotation-reply"></i>&nbsp;<strong>Reply</strong>&nbsp;link.
</p>
</li>
<li class="sidebar-tutorial__list-item" ng-if="vm.canSharePage()">
<p class="sidebar-tutorial__list-item-content">
To share an annotated page, click the
<i class="h-icon-annotation-share"></i>&nbsp;button at the top.
</p>
</li>
<li class="sidebar-tutorial__list-item" ng-if="vm.canCreatePrivateGroup()">
<p class="sidebar-tutorial__list-item-content">
To create a private group, select <strong>Public</strong>,
open the dropdown, click&nbsp;<strong>+&nbsp;New&nbsp;group</strong>.
</p>
</li>
</ol>
</div>
<div class="sheet sheet--is-theme-clean" ng-if="vm.showSidebarTutorial() && vm.isThemeClean">
<i class="close h-icon-close" role="button" title="Close"
ng-click="vm.dismiss()"></i>
<h1 class="sidebar-tutorial__header sidebar-tutorial__header--is-theme-clean">
<i class="h-icon-annotate sidebar-tutorial__header-annotate" h-branding="accentColor"></i>
Start annotating
</h1>
<ol class="sidebar-tutorial__list">
<li class="sidebar-tutorial__list-item sidebar-tutorial__list-item--is-theme-clean">
<div class="sidebar-tutorial__list-item-content">
Select some text to
<span class="sidebar-tutorial__list-item-annotate">annotate</span>
<svg-icon class="sidebar-tutorial__list-item-cursor" name="'cursor'"></svg-icon>
or highlight.
</div>
</li>
<li class="sidebar-tutorial__list-item sidebar-tutorial__list-item--is-theme-clean">
<div class="sidebar-tutorial__list-item-content sidebar-tutorial__list-item-content--is-theme-clean">
Create page level notes
<span class="sidebar-tutorial__list-item-new-note-btn" h-branding="ctaBackgroundColor">
+ New note
</span>
</div>
</li>
<li class="sidebar-tutorial__list-item sidebar-tutorial__list-item--is-theme-clean">
<div class="sidebar-tutorial__list-item-content">
View annotations through your profile
<i class="h-icon-account sidebar-tutorial__list-item-profile"></i>
<i class="h-icon-arrow-drop-down sidebar-tutorial__list-item-drop-down"></i>
</div>
</li>
</ol>
</div>
......@@ -5,6 +5,7 @@
*/
module.exports = {
PANEL_HELP: 'help',
PANEL_SHARE_ANNOTATIONS: 'shareGroupAnnotations',
TAB_ANNOTATIONS: 'annotation',
TAB_NOTES: 'note',
......
.help-panel {
@include font-normal;
background: $grey-3;
margin-bottom: 0.72em;
padding: $layout-h-margin;
border-radius: 2px;
}
&__sub-panel-title {
margin: 0;
padding: 0.5em;
text-align: center;
font-size: 1.25em;
font-weight: 600;
}
.help-panel-title {
color: $grey-6;
font-weight: bold;
// Margin between top of the dialog and
// top of x-height of title should be ~15px.
margin-top: -5px;
}
&__content {
padding: 0.5em;
border-top: 1px solid $grey-3;
border-bottom: 1px solid $grey-3;
line-height: $normal-line-height;
font-size: $normal-font-size;
}
.help-panel-content {
// Margin between bottom of ascent of title and
// top of x-height of content should be 20px.
margin-top: 11px;
}
&__icon {
width: 12px;
height: 12px;
}
.help-panel-content__key {
width: 100px;
float: left;
color: $grey-4;
}
&__footer {
padding: 0.5em 0;
display: flex;
align-items: center;
}
.help-panel-content__val {
word-wrap: break-word;
margin-left: 100px;
}
&__sub-panel-link {
display: flex;
align-items: center;
color: $brand;
.help-panel-content {
margin-top: 10px;
margin-bottom: 15px;
}
&--right {
margin-left: auto;
}
.help-panel-content__link {
color: $grey-6;
text-decoration: underline;
&:hover {
text-decoration: underline;
&-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;
}
&__icon {
width: 12px;
height: 12px;
}
}
}
......@@ -15,6 +15,16 @@
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 {
color: $brand;
font-size: $body2-font-size;
......@@ -44,7 +54,7 @@
}
&__content {
padding: 0.75em;
padding-top: 0;
margin: 1em;
margin-top: 0;
}
}
.sidebar-tutorial__header {
color: $color-cardinal;
font-size: $body1-font-size;
font-weight: $title-font-weight;
}
.sidebar-tutorial__header-annotate {
position: relative;
top: 2px;
}
.sidebar-tutorial__header--is-theme-clean {
color: $gray-dark;
font-size: 16px;
}
/* We want an ordered list in which the numbers are aligned with the text
above (not indented or dedented), and wrapped lines in list items are
aligned with the first character of the list item (not the number, i.e
they're indented):
This is a list:
1. This is a list item.
2. This is a long list item
that wraps.
3. This is another list item.
What's more, we want the numbers to be a different color to the text of the
list items, which means we need to use ::before content for them.
This appears to be much harder than you'd think.
The code below comes from this Stack Overflow answer:
http://stackoverflow.com/questions/10428720/how-to-keep-indent-for-second-line-in-ordered-lists-via-css/17515230#17515230
*/
.sidebar-tutorial__list {
counter-reset: sidebar-tutorial__list;
display: table;
padding: 0;
}
.sidebar-tutorial__list-item {
list-style: none;
counter-increment: sidebar-tutorial__list;
display: table-row;
}
.sidebar-tutorial__list-item::before {
content: counter(sidebar-tutorial__list) '.';
display: table-cell; /* aha! */
text-align: right;
padding-right: 0.3em;
color: $gray-light;
}
.sidebar-tutorial__list-item--is-theme-clean {
font-size: 14px;
}
.sidebar-tutorial__list-item--is-theme-clean::before {
color: $gray-dark;
}
.sidebar-tutorial__list-item-content {
margin-top: 1em; /* Put vertical space in-between list items, equal to the
space between normal paragraphs.
Note: This also puts the same amount of space above the
first list item (i.e. between the list and whatever's
above it). */
}
.sidebar-tutorial__list-item-annotate {
background-color: $highlight-color-focus;
padding: 0px 3px;
}
.sidebar-tutorial__list-item-new-note-btn {
background-color: $color-dove-gray;
border: none;
border-radius: 3px;
color: #fff;
font-weight: 500;
text-align: center;
margin-left: 2px;
padding: 2px 5px;
}
.sidebar-tutorial__list-item-drop-down {
margin-left: -5px;
}
.sidebar-tutorial__list-item-cursor {
position: relative;
top: 3px;
margin-left: -10px;
}
.sidebar-tutorial__list-item-cursor svg {
width: 12px;
height: 17px;
}
.sidebar-tutorial__list-item-content {
margin-top: 16px;
}
.sidebar-tutorial__list-item-profile {
margin-left: 4px;
}
.tutorial {
&__list {
margin-top: 1em;
padding-left: 2em;
}
&__item {
margin: 0.5em 0;
}
&__icon {
width: 12px;
height: 12px;
margin-right: 1px;
margin-bottom: -1px; // Pull the icon a little toward the baseline
color: $grey-5;
}
&__instruction {
white-space: nowrap; // Keep icons and their associated names on the same line
}
}
.version-info {
margin-bottom: 0;
&__key {
float: left;
width: 8em;
margin-bottom: 0.5em;
padding-right: 1em;
line-height: 1.25em;
text-align: right;
font-weight: 500;
color: $grey-6;
}
&__value {
margin-bottom: 0.5em;
margin-left: 8em;
overflow-wrap: break-word; // Keep really long userids from overflowing
color: $grey-6;
}
}
......@@ -45,13 +45,14 @@ $base-line-height: 20px;
@import './components/share-annotations-panel';
@import './components/search-input';
@import './components/sidebar-panel';
@import './components/sidebar-tutorial';
@import './components/svg-icon';
@import './components/spinner';
@import './components/tags-input';
@import './components/thread-list';
@import './components/tooltip';
@import './components/top-bar';
@import './components/tutorial';
@import './components/version-info';
// Top-level styles
// ----------------
......
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