Unverified Commit 521b1ffd authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1154 from hypothesis/user-menu

Convert portions of login-control component to (preact) UserMenu component
parents 2f8d2eb5 d2a8e581
...@@ -5,9 +5,9 @@ ...@@ -5,9 +5,9 @@
<desc>Created with Sketch.</desc> <desc>Created with Sketch.</desc>
<defs></defs> <defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="Artboard-1-Copy" sketch:type="MSArtboardGroup" fill="#000000"> <g id="Artboard-1-Copy" sketch:type="MSArtboardGroup" fill="currentColor">
<circle id="Oval-1" sketch:type="MSShapeGroup" cx="8" cy="4" r="3"></circle> <circle id="Oval-1" sketch:type="MSShapeGroup" cx="8" cy="4" r="3"></circle>
<path d="M8,15 C11,15 14,14.4329966 14,12 C14,10.5670034 10,8 8,8 C6,8 2,10.5670034 2,12 C2,14.4329966 5,15 8,15 Z" id="Oval-1-Copy" sketch:type="MSShapeGroup"></path> <path d="M8,15 C11,15 14,14.4329966 14,12 C14,10.5670034 10,8 8,8 C6,8 2,10.5670034 2,12 C2,14.4329966 5,15 8,15 Z" id="Oval-1-Copy" sketch:type="MSShapeGroup"></path>
</g> </g>
</g> </g>
</svg> </svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="nonzero" d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16zm-.5-5.805c.496 0 .768-.29.873-.788.091-.623.3-.943 1.23-1.515.987-.616 1.497-1.381 1.497-2.503C11.1 3.66 9.765 2.5 7.779 2.5c-1.503 0-2.622.631-3.07 1.612-.14.282-.209.564-.209.884 0 .564.343.928.895.928.426 0 .741-.208.916-.683.224-.661.685-1.018 1.37-1.018.77 0 1.3.505 1.3 1.233 0 .683-.272 1.055-1.174 1.627-.825.512-1.251 1.092-1.251 1.968v.104c0 .609.35 1.04.943 1.04zm.013 3.305c.637 0 1.147-.512 1.147-1.166 0-.654-.51-1.166-1.147-1.166-.629 0-1.14.512-1.14 1.166 0 .654.511 1.166 1.14 1.166z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#000" fill-rule="nonzero" d="M16 1.656v3.5c0 .587-.721.874-1.138.464l-.992-.977-6.764 6.66a.674.674 0 0 1-.943 0l-.629-.62a.649.649 0 0 1 0-.927l6.765-6.66-.992-.976c-.419-.412-.122-1.12.471-1.12h3.555c.369 0 .667.294.667.656zm-4.694 6.749c.42-.414 1.138-.121 1.138.464v4.819c0 .724-.597 1.312-1.333 1.312H1.333A1.323 1.323 0 0 1 0 13.687V4.063C0 3.338.597 2.75 1.333 2.75h8.223c.593 0 .89.707.47 1.12l-.444.438a.772.772 0 0 1-.47.192H1.777v8.75h8.889V9.306c0-.15.087-.358.195-.464l.444-.437z"/> <path fill="currentColor" fill-rule="nonzero" d="M16 1.656v3.5c0 .587-.721.874-1.138.464l-.992-.977-6.764 6.66a.674.674 0 0 1-.943 0l-.629-.62a.649.649 0 0 1 0-.927l6.765-6.66-.992-.976c-.419-.412-.122-1.12.471-1.12h3.555c.369 0 .667.294.667.656zm-4.694 6.749c.42-.414 1.138-.121 1.138.464v4.819c0 .724-.597 1.312-1.333 1.312H1.333A1.323 1.323 0 0 1 0 13.687V4.063C0 3.338.597 2.75 1.333 2.75h8.223c.593 0 .89.707.47 1.12l-.444.438a.772.772 0 0 1-.47.192H1.777v8.75h8.889V9.306c0-.15.087-.358.195-.464l.444-.437z"/>
</svg> </svg>
\ No newline at end of file
'use strict'; 'use strict';
const bridgeEvents = require('../../shared/bridge-events');
const { isThirdPartyUser } = require('../util/account-id');
const serviceConfig = require('../service-config');
module.exports = { module.exports = {
controllerAs: 'vm', controllerAs: 'vm',
//@ngInject //@ngInject
controller: function(bridge, serviceUrl, settings, $window) { controller: function() {},
this.serviceUrl = serviceUrl;
this.isThirdPartyUser = function() {
return isThirdPartyUser(this.auth.userid, settings.authDomain);
};
this.shouldShowLogOutButton = function() {
if (this.auth.status !== 'logged-in') {
return false;
}
const service = serviceConfig(settings);
if (service && !service.onLogoutRequestProvided) {
return false;
}
return true;
};
this.shouldEnableProfileButton = function() {
const service = serviceConfig(settings);
if (service) {
return service.onProfileRequestProvided;
}
return true;
};
this.showProfile = function() {
if (this.isThirdPartyUser()) {
bridge.call(bridgeEvents.PROFILE_REQUESTED);
return;
}
$window.open(this.serviceUrl('user', { user: this.auth.username }));
};
},
bindings: { bindings: {
/** /**
...@@ -51,10 +14,6 @@ module.exports = { ...@@ -51,10 +14,6 @@ module.exports = {
/** /**
* Called when the user clicks on the "About this version" text. * Called when the user clicks on the "About this version" text.
*/ */
onShowHelpPanel: '&',
/**
* Called when the user clicks on the "Log in" text.
*/
onLogin: '&', onLogin: '&',
/** /**
* Called when the user clicks on the "Sign Up" text. * Called when the user clicks on the "Sign Up" text.
...@@ -64,12 +23,6 @@ module.exports = { ...@@ -64,12 +23,6 @@ module.exports = {
* Called when the user clicks on the "Log out" text. * Called when the user clicks on the "Log out" text.
*/ */
onLogout: '&', onLogout: '&',
/**
* Whether or not to use the new design for the control.
*
* FIXME: should be removed when the old design is deprecated.
*/
newStyle: '<',
}, },
template: require('../templates/login-control.html'), template: require('../templates/login-control.html'),
}; };
...@@ -150,7 +150,10 @@ Menu.propTypes = { ...@@ -150,7 +150,10 @@ Menu.propTypes = {
/** /**
* Label element for the toggle button that hides and shows the menu. * Label element for the toggle button that hides and shows the menu.
*/ */
label: propTypes.object.isRequired, label: propTypes.oneOfType([
propTypes.object.isRequired,
propTypes.string.isRequired,
]),
/** /**
* Menu items and sections to display in the content area of the menu. * Menu items and sections to display in the content area of the menu.
......
...@@ -11,6 +11,7 @@ const icons = { ...@@ -11,6 +11,7 @@ const icons = {
'expand-menu': require('../../images/icons/expand-menu.svg'), 'expand-menu': require('../../images/icons/expand-menu.svg'),
copy: require('../../images/icons/copy.svg'), copy: require('../../images/icons/copy.svg'),
cursor: require('../../images/icons/cursor.svg'), cursor: require('../../images/icons/cursor.svg'),
help: require('../../images/icons/help.svg'),
leave: require('../../images/icons/leave.svg'), leave: require('../../images/icons/leave.svg'),
refresh: require('../../images/icons/refresh.svg'), refresh: require('../../images/icons/refresh.svg'),
share: require('../../images/icons/share.svg'), share: require('../../images/icons/share.svg'),
...@@ -40,7 +41,9 @@ function SvgIcon({ name, className = '' }) { ...@@ -40,7 +41,9 @@ function SvgIcon({ name, className = '' }) {
markup, markup,
]); ]);
return <span dangerouslySetInnerHTML={markup} ref={element} />; return (
<span className="svg-icon" dangerouslySetInnerHTML={markup} ref={element} />
);
} }
SvgIcon.propTypes = { SvgIcon.propTypes = {
......
...@@ -2,350 +2,83 @@ ...@@ -2,350 +2,83 @@
const angular = require('angular'); const angular = require('angular');
const bridgeEvents = require('../../../shared/bridge-events');
const util = require('../../directive/test/util'); const util = require('../../directive/test/util');
const loginControl = require('../login-control'); const loginControl = require('../login-control');
function pageObject(element) {
return {
menuLinks: function() {
return Array.from(
element[0].querySelectorAll('.login-control-menu .dropdown-menu a')
).map(function(el) {
return el.textContent;
});
},
menuText: function() {
return element[0].querySelector('span').textContent;
},
userProfileButton: element[0].querySelector('.js-user-profile-btn'),
accountSettingsButton: element[0].querySelector('.js-account-settings-btn'),
helpButton: element[0].querySelector('.js-help-btn'),
logOutButton: element[0].querySelector('.js-log-out-btn'),
};
}
function createLoginControl(inputs) {
return util.createDirective(
document,
'loginControl',
Object.assign({}, inputs)
);
}
function unknownAuthStatusPage() {
return pageObject(
createLoginControl({
auth: { status: 'unknown' },
newStyle: true,
})
);
}
function loggedOutPage() {
return pageObject(
createLoginControl({
auth: { status: 'logged-out' },
newStyle: true,
})
);
}
function firstPartyUserPage() {
return pageObject(
createLoginControl({
auth: {
displayName: 'Jim Smith',
username: 'someUsername',
status: 'logged-in',
},
newStyle: true,
})
);
}
function thirdPartyUserPage() {
return pageObject(
createLoginControl({
auth: {
userid: 'acct:someUsername@anotherFakeDomain',
username: 'someUsername',
status: 'logged-in',
},
newStyle: true,
})
);
}
describe('loginControl', function() { describe('loginControl', function() {
let fakeBridge;
let fakeServiceConfig;
let fakeWindow;
before(function() { before(function() {
angular.module('app', []).component('loginControl', loginControl); angular.module('app', []).component('loginControl', loginControl);
}); });
beforeEach(function() { beforeEach(function() {
fakeServiceConfig = sinon.stub().returns(null); angular.mock.module('app', {});
fakeBridge = { call: sinon.stub() };
const fakeServiceUrl = sinon.stub().returns('someUrl');
const fakeSettings = {
authDomain: 'fakeDomain',
};
fakeWindow = { open: sinon.stub() };
angular.mock.module('app', {
bridge: fakeBridge,
serviceUrl: fakeServiceUrl,
settings: fakeSettings,
$window: fakeWindow,
});
loginControl.$imports.$mock({
'../service-config': fakeServiceConfig,
});
});
afterEach(() => {
loginControl.$imports.$restore();
});
describe('the user profile button', function() {
/**
* Return true if the user profile button is enabled, false if it's
* disabled, and null if no user profile button is rendered.
*/
function isUserProfileButtonEnabled(page) {
if (page.userProfileButton === null) {
return null;
}
if (page.userProfileButton.classList.contains('is-enabled')) {
return true;
}
return false;
}
context('when the user auth status is unknown', function() {
it('does not show the user profile button', function() {
assert.isNull(isUserProfileButtonEnabled(unknownAuthStatusPage()));
});
});
context('when the user is logged out', function() {
it('does not show the user profile button', function() {
assert.isNull(isUserProfileButtonEnabled(loggedOutPage()));
});
});
context('when a first-party user is logged in', function() {
it('shows the enabled user profile button', function() {
assert.isTrue(isUserProfileButtonEnabled(firstPartyUserPage()));
});
it('displays the display name', () => {
const profileBtn = firstPartyUserPage().userProfileButton;
assert.equal(profileBtn.textContent, 'Jim Smith');
});
it('does not send any events', function() {
firstPartyUserPage().userProfileButton.click();
assert.notCalled(fakeBridge.call);
});
it('opens a new tab', function() {
firstPartyUserPage().userProfileButton.click();
assert.calledOnce(fakeWindow.open);
assert.calledWithExactly(fakeWindow.open, 'someUrl');
});
});
context('when a third-party user is logged in', function() {
context("when there's no onProfileRequest callback", function() {
beforeEach('provide a service with no onProfileRequest', function() {
fakeServiceConfig.returns({});
});
it('shows the disabled user profile button', function() {
assert.isFalse(isUserProfileButtonEnabled(thirdPartyUserPage()));
});
});
context("when there's an onProfileRequest callback", function() {
beforeEach('provide an onProfileRequest callback', function() {
fakeServiceConfig.returns({ onProfileRequestProvided: true });
});
it('shows the enabled user profile button', function() {
assert.isTrue(isUserProfileButtonEnabled(thirdPartyUserPage()));
});
it('sends the PROFILE_REQUESTED event', function() {
thirdPartyUserPage().userProfileButton.click();
assert.calledOnce(fakeBridge.call);
assert.calledWithExactly(
fakeBridge.call,
bridgeEvents.PROFILE_REQUESTED
);
});
it('does not open a new tab', function() {
thirdPartyUserPage().userProfileButton.click();
assert.notCalled(fakeWindow.open);
});
});
});
});
describe('the account settings button', function() {
context('when the user auth status is unknown', function() {
it('does not show', function() {
assert.isNull(unknownAuthStatusPage().accountSettingsButton);
});
});
context('when the user is logged out', function() {
it('does not show', function() {
assert.isNull(loggedOutPage().accountSettingsButton);
});
});
context('when a first-party user is logged in', function() {
it('does show', function() {
assert.isNotNull(firstPartyUserPage().accountSettingsButton);
});
});
context('when a third-party user is logged in', function() {
it('does not show', function() {
assert.isNull(thirdPartyUserPage().accountSettingsButton);
});
});
});
describe('the help button', function() {
context('when the user auth status is unknown', function() {
it('does show', function() {
assert.isNotNull(unknownAuthStatusPage().helpButton);
});
});
context('when the user is logged out', function() {
it('does show', function() {
assert.isNotNull(loggedOutPage().helpButton);
});
});
context('when a first-party user is logged in', function() {
it('does show', function() {
assert.isNotNull(firstPartyUserPage().helpButton);
});
});
context('when a third-party user is logged in', function() {
it('does show', function() {
assert.isNotNull(thirdPartyUserPage().helpButton);
});
});
}); });
describe('the log out button', function() { describe('sign up and log in links', () => {
context('when the user auth status is unknown', function() { it('should render empty login and signup element if user auth status is unknown', () => {
it('does not show', function() { const el = util.createDirective(document, 'loginControl', {
assert.isNull(unknownAuthStatusPage().logOutButton); auth: {
}); username: 'someUsername',
}); status: 'unknown',
},
context('when the user is logged out', function() { newStyle: true,
it('does not show', function() {
assert.isNull(loggedOutPage().logOutButton);
});
});
context('when a first-party user is logged in', function() {
it('does show', function() {
assert.isNotNull(firstPartyUserPage().logOutButton);
}); });
const loginEl = el.find('.login-text');
const links = loginEl.find('a');
assert.lengthOf(loginEl, 1);
assert.lengthOf(links, 0);
}); });
context('when a third-party user is logged in', function() { it('should render login and signup links if user is logged out', () => {
context("when there's no onLogoutRequest callback", function() { const el = util.createDirective(document, 'loginControl', {
beforeEach('provide a service with no onLogoutRequest', function() { auth: {
fakeServiceConfig.returns({}); username: 'someUsername',
}); status: 'logged-out',
},
it('does not show', function() { newStyle: true,
assert.isNull(thirdPartyUserPage().logOutButton);
});
});
context("when there's an onLogoutRequest callback", function() {
beforeEach('provide an onLogoutRequest callback', function() {
fakeServiceConfig.returns({ onLogoutRequestProvided: true });
});
it('does show', function() {
assert.isNotNull(thirdPartyUserPage().logOutButton);
});
}); });
const loginEl = el.find('.login-text');
const links = loginEl.find('a');
assert.lengthOf(loginEl, 1);
assert.lengthOf(links, 2);
}); });
});
context('old controls when a H user is logged in', function() { it('should not render login and signup element if user is logged in', () => {
it('shows the complete list of menu options', function() { const el = util.createDirective(document, 'loginControl', {
const el = createLoginControl({
auth: { auth: {
username: 'someUsername', username: 'someUsername',
status: 'logged-in', status: 'logged-in',
}, },
newStyle: false, newStyle: true,
}); });
const page = pageObject(el); const loginEl = el.find('.login-text');
assert.lengthOf(loginEl, 0);
assert.deepEqual(page.menuLinks(), [
'Account',
'Help',
'My Annotations',
'Log out',
]);
assert.include(page.menuText(), 'someUsername');
}); });
}); });
context('old controls when user is logged out', function() { describe('user menu', () => {
it('shows the help and log in menu options', function() { it('should render a user menu if the user is logged in', () => {
const el = createLoginControl({ const el = util.createDirective(document, 'loginControl', {
auth: { auth: {
status: 'logged-out', username: 'someUsername',
status: 'logged-in',
}, },
newStyle: false, newStyle: true,
}); });
const page = pageObject(el); const menuEl = el.find('user-menu');
assert.lengthOf(menuEl, 1);
assert.include(page.menuText(), 'Log in');
assert.deepEqual(page.menuLinks(), ['Help']);
}); });
}); it('should not render a user menu if user is not logged in', () => {
const el = util.createDirective(document, 'loginControl', {
context('old controls when auth status is unknown', function() {
it('shows the help menu option', function() {
const el = createLoginControl({
auth: { auth: {
status: 'unknown', username: 'someUsername',
status: 'logged-out',
}, },
newStyle: false, newStyle: true,
}); });
const page = pageObject(el); const menuEl = el.find('user-menu');
assert.lengthOf(menuEl, 0);
assert.equal(page.menuText(), '⋯');
assert.deepEqual(page.menuLinks(), ['Help']);
}); });
}); });
}); });
...@@ -41,6 +41,10 @@ describe('topBar', function() { ...@@ -41,6 +41,10 @@ describe('topBar', function() {
return el.querySelector('.top-bar__apply-update-btn'); return el.querySelector('.top-bar__apply-update-btn');
} }
function helpBtn(el) {
return el.querySelector('.top-bar__help-btn');
}
function createTopBar(inputs) { function createTopBar(inputs) {
const defaultInputs = { const defaultInputs = {
isSidebar: true, isSidebar: true,
...@@ -79,6 +83,16 @@ describe('topBar', function() { ...@@ -79,6 +83,16 @@ describe('topBar', function() {
assert.called(onApplyPendingUpdates); assert.called(onApplyPendingUpdates);
}); });
it('shows help when help icon clicked', function() {
const onShowHelpPanel = sinon.stub();
const el = createTopBar({
onShowHelpPanel: onShowHelpPanel,
});
const help = helpBtn(el[0]);
help.click();
assert.called(onShowHelpPanel);
});
it('displays the login control and propagates callbacks', function() { it('displays the login control and propagates callbacks', function() {
const onShowHelpPanel = sinon.stub(); const onShowHelpPanel = sinon.stub();
const onLogin = sinon.stub(); const onLogin = sinon.stub();
...@@ -90,9 +104,6 @@ describe('topBar', function() { ...@@ -90,9 +104,6 @@ describe('topBar', function() {
}); });
const loginControl = el.find('login-control').controller('loginControl'); const loginControl = el.find('login-control').controller('loginControl');
loginControl.onShowHelpPanel();
assert.called(onShowHelpPanel);
loginControl.onLogin(); loginControl.onLogin();
assert.called(onLogin); assert.called(onLogin);
......
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const UserMenu = require('../user-menu');
const MenuItem = require('../menu-item');
describe('UserMenu', () => {
let fakeAuth;
let fakeBridge;
let fakeIsThirdPartyUser;
let fakeOnLogout;
let fakeProfileBridgeEvent;
let fakeServiceConfig;
let fakeServiceUrl;
let fakeSettings;
const createUserMenu = () => {
return shallow(
<UserMenu
auth={fakeAuth}
bridge={fakeBridge}
onLogout={fakeOnLogout}
serviceUrl={fakeServiceUrl}
settings={fakeSettings}
/>
).dive(); // Dive needed because this component uses `withServices`
};
const findMenuItem = (wrapper, labelText) => {
return wrapper
.find(MenuItem)
.filterWhere(n => n.prop('label') === labelText);
};
beforeEach(() => {
fakeAuth = {
displayName: 'Eleanor Fishtail',
status: 'logged-in',
userid: 'acct:eleanorFishtail@hypothes.is',
username: 'eleanorFishy',
};
fakeBridge = { call: sinon.stub() };
fakeIsThirdPartyUser = sinon.stub();
fakeOnLogout = sinon.stub();
fakeProfileBridgeEvent = 'profile-requested';
fakeServiceConfig = sinon.stub();
fakeServiceUrl = sinon.stub();
fakeSettings = {
authDomain: 'hypothes.is',
};
UserMenu.$imports.$mock({
'../util/account-id': {
isThirdPartyUser: fakeIsThirdPartyUser,
},
'../service-config': fakeServiceConfig,
'../../shared/bridge-events': {
PROFILE_REQUESTED: fakeProfileBridgeEvent,
},
});
});
afterEach(() => {
UserMenu.$imports.$restore();
});
describe('profile menu item', () => {
context('first-party user', () => {
beforeEach(() => {
fakeIsThirdPartyUser.returns(false);
fakeServiceUrl.returns('profile-link');
});
it('should be enabled', () => {
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
assert.notOk(profileMenuItem.prop('isDisabled'));
});
it('should have a link (href)', () => {
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
assert.equal(profileMenuItem.prop('href'), 'profile-link');
});
it('should have a callback', () => {
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
assert.isFunction(profileMenuItem.prop('onClick'));
});
});
context('third-party user', () => {
beforeEach(() => {
fakeIsThirdPartyUser.returns(true);
});
it('should be disabled if no service configured', () => {
fakeServiceConfig.returns(null);
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
assert.isTrue(profileMenuItem.prop('isDisabled'));
});
it('should be disabled if service feature not supported', () => {
fakeServiceConfig.returns({ onProfileRequestProvided: false });
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
assert.isTrue(profileMenuItem.prop('isDisabled'));
});
it('should be enabled if service feature support', () => {
fakeServiceConfig.returns({ onProfileRequestProvided: true });
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
assert.notOk(profileMenuItem.prop('isDisabled'));
});
it('should have a callback if enabled', () => {
fakeServiceConfig.returns({ onProfileRequestProvided: true });
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
assert.isFunction(profileMenuItem.prop('onClick'));
});
});
describe('profile-selected callback', () => {
it('should fire profile event for third-party user', () => {
fakeServiceConfig.returns({ onProfileRequestProvided: true });
fakeIsThirdPartyUser.returns(true);
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
const onProfileSelected = profileMenuItem.prop('onClick');
onProfileSelected();
assert.equal(fakeBridge.call.callCount, 1);
assert.calledWith(fakeBridge.call, fakeProfileBridgeEvent);
});
it('should not fire profile event for first-party user', () => {
fakeIsThirdPartyUser.returns(false);
const wrapper = createUserMenu();
const profileMenuItem = findMenuItem(wrapper, fakeAuth.displayName);
const onProfileSelected = profileMenuItem.prop('onClick');
onProfileSelected();
assert.equal(fakeBridge.call.callCount, 0);
});
});
});
describe('account settings menu item', () => {
it('should be present if first-party user', () => {
fakeIsThirdPartyUser.returns(false);
const wrapper = createUserMenu();
const accountMenuItem = findMenuItem(wrapper, 'Account settings');
assert.isTrue(accountMenuItem.exists());
assert.calledWith(fakeServiceUrl, 'account.settings');
});
it('should not be present if third-party user', () => {
fakeIsThirdPartyUser.returns(true);
const wrapper = createUserMenu();
const accountMenuItem = findMenuItem(wrapper, 'Account settings');
assert.isFalse(accountMenuItem.exists());
});
});
describe('log out menu item', () => {
const tests = [
{
it: 'should be present for first-party user if no service configured',
isThirdParty: false,
serviceConfigReturns: null,
expected: true,
},
{
it:
'should be present for first-party user if service supports `onLogoutRequest`',
isThirdParty: false,
serviceConfigReturns: { onLogoutRequestProvided: true },
expected: true,
},
{
it:
'should be present for first-party user if service does not support `onLogoutRequest`',
isThirdParty: false,
serviceConfigReturns: { onLogoutRequestProvided: false },
expected: true,
},
{
it: 'should be absent for third-party user if no service configured',
isThirdParty: true,
serviceConfigReturns: null,
expected: false,
},
{
it:
'should be present for third-party user if service supports `onLogoutRequest`',
isThirdParty: true,
serviceConfigReturns: { onLogoutRequestProvided: true },
expected: true,
},
{
it:
'should be absent for third-party user if `onLogoutRequest` not supported',
isThirdParty: true,
serviceConfigReturns: { onLogoutRequestProvided: false },
expected: false,
},
];
tests.forEach(test => {
it(test.it, () => {
fakeIsThirdPartyUser.returns(test.isThirdParty);
fakeServiceConfig.returns(test.serviceConfigReturns);
const wrapper = createUserMenu();
const logOutMenuItem = findMenuItem(wrapper, 'Log out');
assert.equal(logOutMenuItem.exists(), test.expected);
if (test.expected) {
assert.equal(logOutMenuItem.prop('onClick'), fakeOnLogout);
}
});
});
});
});
'use strict';
const { createElement } = require('preact');
const propTypes = require('prop-types');
const bridgeEvents = require('../../shared/bridge-events');
const { isThirdPartyUser } = require('../util/account-id');
const serviceConfig = require('../service-config');
const { withServices } = require('../util/service-context');
const Menu = require('./menu');
const MenuSection = require('./menu-section');
const MenuItem = require('./menu-item');
/**
* A menu with user and account links.
*
* This menu will contain different items depending on service configuration,
* context and whether the user is first- or third-party.
*/
function UserMenu({ auth, bridge, onLogout, serviceUrl, settings }) {
const isThirdParty = isThirdPartyUser(auth.userid, settings.authDomain);
const service = serviceConfig(settings);
const serviceSupports = feature => service && !!service[feature];
const isSelectableProfile =
!isThirdParty || serviceSupports('onProfileRequestProvided');
const isLogoutEnabled =
!isThirdParty || serviceSupports('onLogoutRequestProvided');
const onProfileSelected = () =>
isThirdParty && bridge.call(bridgeEvents.PROFILE_REQUESTED);
// Generate dynamic props for the profile <MenuItem> component
const profileItemProps = (() => {
const props = {};
if (isSelectableProfile) {
if (!isThirdParty) {
props.href = serviceUrl('user', { user: auth.username });
}
props.onClick = onProfileSelected;
}
return props;
})();
const menuLabel = <i className="h-icon-account top-bar__btn" />;
return (
<div className="user-menu">
<Menu label={menuLabel} title={auth.displayName} align="right">
<MenuSection>
<MenuItem
label={auth.displayName}
isDisabled={!isSelectableProfile}
{...profileItemProps}
/>
{!isThirdParty && (
<MenuItem
label="Account settings"
href={serviceUrl('account.settings')}
/>
)}
</MenuSection>
{isLogoutEnabled && (
<MenuSection>
<MenuItem label="Log out" onClick={onLogout} />
</MenuSection>
)}
</Menu>
</div>
);
}
UserMenu.propTypes = {
/* object representing authenticated user and auth status */
auth: propTypes.object.isRequired,
/* onClick callback for the "log out" button */
onLogout: propTypes.func.isRequired,
/* services */
bridge: propTypes.object.isRequired,
serviceUrl: propTypes.func.isRequired,
settings: propTypes.object.isRequired,
};
UserMenu.injectedProps = ['bridge', 'serviceUrl', 'settings'];
module.exports = withServices(UserMenu);
...@@ -198,6 +198,10 @@ function startAngularApp(config) { ...@@ -198,6 +198,10 @@ function startAngularApp(config) {
wrapReactComponent(require('./components/timestamp')) wrapReactComponent(require('./components/timestamp'))
) )
.component('topBar', require('./components/top-bar')) .component('topBar', require('./components/top-bar'))
.component(
'userMenu',
wrapReactComponent(require('./components/user-menu'))
)
.directive('hAutofocus', require('./directive/h-autofocus')) .directive('hAutofocus', require('./directive/h-autofocus'))
.directive('hBranding', require('./directive/h-branding')) .directive('hBranding', require('./directive/h-branding'))
......
<!-- New controls --> <!-- New controls -->
<span class="login-text" <span class="login-text"
ng-if="vm.newStyle && vm.auth.status === 'unknown'"></span> ng-if="vm.auth.status === 'unknown'"></span>
<span class="login-text" <span class="login-text"
ng-if="vm.newStyle && vm.auth.status === 'logged-out'"> ng-if="vm.auth.status === 'logged-out'">
<a href="" ng-click="vm.onSignUp()" target="_blank" h-branding="accentColor">Sign up</a> <a href="" ng-click="vm.onSignUp()" target="_blank" h-branding="accentColor">Sign up</a>
/ <a href="" ng-click="vm.onLogin()" h-branding="accentColor">Log in</a> / <a href="" ng-click="vm.onLogin()" h-branding="accentColor">Log in</a>
</span> </span>
<div ng-if="vm.newStyle"
class="pull-right login-control-menu"
dropdown
keyboard-nav>
<a role="button"
class="top-bar__btn"
data-toggle="dropdown"
dropdown-toggle
title="{{vm.auth.username}}">
<i class="h-icon-account" ng-if="vm.auth.status === 'logged-in'"></i><!--
!--><i class="h-icon-arrow-drop-down top-bar__dropdown-arrow"></i>
</a>
<ul class="dropdown-menu pull-right" role="menu">
<li class="dropdown-menu__row" ng-if="vm.auth.status === 'logged-in'">
<span ng-if="!vm.shouldEnableProfileButton()"
class="dropdown-menu__link dropdown-menu__link--disabled js-user-profile-btn is-disabled">
{{vm.auth.displayName}}</span>
<a ng-if="vm.shouldEnableProfileButton()"
ng-click="vm.showProfile()"
class="dropdown-menu__link js-user-profile-btn is-enabled"
title="View all your annotations"
target="_blank">{{vm.auth.displayName}}</a>
</li>
<li class="dropdown-menu__row" ng-if="vm.auth.status === 'logged-in' && !vm.isThirdPartyUser()">
<a class="dropdown-menu__link js-account-settings-btn" href="{{vm.serviceUrl('account.settings')}}" target="_blank">Account settings</a>
</li>
<li class="dropdown-menu__row">
<a class="dropdown-menu__link js-help-btn" ng-click="vm.onShowHelpPanel()">Help</a>
</li>
<li class="dropdown-menu__row" ng-if="vm.shouldShowLogOutButton()">
<a class="dropdown-menu__link dropdown-menu__link--subtle js-log-out-btn"
href="" ng-click="vm.onLogout()">Log out</a>
</li>
</ul>
</div>
<!-- Old controls --> <user-menu auth="vm.auth" on-logout="vm.onLogout()" ng-if="vm.auth.status === 'logged-in'"/>
<span ng-if="!vm.newStyle && vm.auth.status === 'unknown'"></span>
<span ng-if="!vm.newStyle && vm.auth.status === 'logged-out'">
<a href="" ng-click="vm.onLogin()">Log in</a>
</span>
<div ng-if="!vm.newStyle"
class="pull-right login-control-menu"
dropdown
keyboard-nav>
<span role="button" data-toggle="dropdown" dropdown-toggle>
{{vm.auth.username}}<!--
--><span class="provider"
ng-if="vm.auth.provider">/{{vm.auth.provider}}</span><!--
--><i class="h-icon-arrow-drop-down"></i>
</span>
<ul class="dropdown-menu pull-right" role="menu">
<li class="dropdown-menu__row" ng-if="vm.auth.status === 'logged-in'">
<a class="dropdown-menu__link" href="{{vm.serviceUrl('account.settings')}}" target="_blank">Account</a>
</li>
<li class="dropdown-menu__row" >
<a class="dropdown-menu__link" ng-click="vm.onShowHelpPanel()">Help</a>
</li>
<li class="dropdown-menu__row" ng-if="vm.auth.status === 'logged-in'">
<a class="dropdown-menu__link" href="{{vm.serviceUrl('user',{user: vm.auth.username})}}"
target="_blank">My Annotations</a>
</li>
<li class="dropdown-menu__row" ng-if="vm.auth.status === 'logged-in'">
<a class="dropdown-menu__link" href="" ng-click="vm.onLogout()">Log out</a>
</li>
</ul>
</div>
...@@ -11,12 +11,18 @@ ...@@ -11,12 +11,18 @@
always-expanded="true"> always-expanded="true">
</search-input> </search-input>
<div class="top-bar__expander"></div> <div class="top-bar__expander"></div>
<button class="top-bar__btn top-bar__help-btn"
ng-click="vm.onShowHelpPanel()"
title="Help"
aria-label="Help">
<svg-icon name="'help'" class-name="'top-bar__help-icon'"></svg-icon>
</button>
<login-control <login-control
class="login-control"
auth="vm.auth" auth="vm.auth"
new-style="false"
on-show-help-panel="vm.onShowHelpPanel()"
on-login="vm.onLogin()" on-login="vm.onLogin()"
on-logout="vm.onLogout()"> on-logout="vm.onLogout()"
on-sign-up="vm.onSignUp()">
</login-control> </login-control>
</div> </div>
<!-- New design for the top bar, as used in the sidebar. <!-- New design for the top bar, as used in the sidebar.
...@@ -49,11 +55,15 @@ ...@@ -49,11 +55,15 @@
aria-label="Share this page"> aria-label="Share this page">
<i class="h-icon-annotation-share"></i> <i class="h-icon-annotation-share"></i>
</button> </button>
<button class="top-bar__btn top-bar__help-btn"
ng-click="vm.onShowHelpPanel()"
title="Help"
aria-label="Help">
<svg-icon name="'help'" class-name="'top-bar__help-icon'"></svg-icon>
</button>
<login-control <login-control
class="login-control" class="login-control"
auth="vm.auth" auth="vm.auth"
new-style="true"
on-show-help-panel="vm.onShowHelpPanel()"
on-login="vm.onLogin()" on-login="vm.onLogin()"
on-logout="vm.onLogout()" on-logout="vm.onLogout()"
on-sign-up="vm.onSignUp()"> on-sign-up="vm.onSignUp()">
......
.login-control{ .login-control {
flex-shrink: 0; flex-shrink: 0;
} }
...@@ -6,22 +6,3 @@ ...@@ -6,22 +6,3 @@
font-size: $body2-font-size; font-size: $body2-font-size;
padding-left: 6px; padding-left: 6px;
} }
/* The user account dropdown menu */
.login-control-menu {
.dropdown-toggle {
.provider {
color: $gray-light;
display: none;
}
&:hover {
.provider {
display: inline;
}
}
}
.dropdown.open .provider {
display: inline;
}
}
/* Make the wrapper element's size match the contained `svg` element */
.svg-icon {
display: flex;
}
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
// Force top-bar onto a new compositor layer so that it does not judder when // Force top-bar onto a new compositor layer so that it does not judder when
// the window is scrolled. // the window is scrolled.
transform: translate3d(0,0,0); transform: translate3d(0, 0, 0);
} }
.top-bar--theme-clean { .top-bar--theme-clean {
...@@ -93,6 +93,12 @@ ...@@ -93,6 +93,12 @@
user-select: none; user-select: none;
} }
/* FIXME: Temporary fix to make 'help' icon align with other top-bar icons */
.top-bar__help-icon {
width: 15px;
height: 15px;
}
.top-bar__apply-icon { .top-bar__apply-icon {
display: inline-block; display: inline-block;
line-height: 12px; line-height: 12px;
......
...@@ -43,6 +43,7 @@ $base-line-height: 20px; ...@@ -43,6 +43,7 @@ $base-line-height: 20px;
@import './components/share-link'; @import './components/share-link';
@import './components/sidebar-tutorial'; @import './components/sidebar-tutorial';
@import './components/simple-search'; @import './components/simple-search';
@import './components/svg-icon';
@import './components/spinner'; @import './components/spinner';
@import './components/tags-input'; @import './components/tags-input';
@import './components/thread-list'; @import './components/thread-list';
...@@ -51,7 +52,8 @@ $base-line-height: 20px; ...@@ -51,7 +52,8 @@ $base-line-height: 20px;
// Top-level styles // Top-level styles
// ---------------- // ----------------
html, body { html,
body {
height: 100%; height: 100%;
} }
...@@ -105,7 +107,7 @@ hypothesis-app { ...@@ -105,7 +107,7 @@ hypothesis-app {
border-radius: 2px; border-radius: 2px;
font-family: $sans-font-family; font-family: $sans-font-family;
font-weight: 300; font-weight: 300;
margin-bottom: .72em; margin-bottom: 0.72em;
padding: 1em; padding: 1em;
position: relative; position: relative;
background-color: $body-background; background-color: $body-background;
...@@ -114,7 +116,9 @@ hypothesis-app { ...@@ -114,7 +116,9 @@ hypothesis-app {
border: 1px none $gray-lighter; border: 1px none $gray-lighter;
border-bottom-style: solid; border-bottom-style: solid;
padding: 0 0 1.1em; padding: 0 0 1.1em;
li a { padding-bottom: .231em } li a {
padding-bottom: 0.231em;
}
} }
.close { .close {
......
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