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 @@
<desc>Created with Sketch.</desc>
<defs></defs>
<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>
<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>
</svg>
\ No newline at end of file
</svg>
<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">
<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"/>
</svg>
\ No newline at end of file
<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>
'use strict';
const bridgeEvents = require('../../shared/bridge-events');
const { isThirdPartyUser } = require('../util/account-id');
const serviceConfig = require('../service-config');
module.exports = {
controllerAs: 'vm',
//@ngInject
controller: function(bridge, serviceUrl, settings, $window) {
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 }));
};
},
controller: function() {},
bindings: {
/**
......@@ -51,10 +14,6 @@ module.exports = {
/**
* Called when the user clicks on the "About this version" text.
*/
onShowHelpPanel: '&',
/**
* Called when the user clicks on the "Log in" text.
*/
onLogin: '&',
/**
* Called when the user clicks on the "Sign Up" text.
......@@ -64,12 +23,6 @@ module.exports = {
* Called when the user clicks on the "Log out" text.
*/
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'),
};
......@@ -150,7 +150,10 @@ Menu.propTypes = {
/**
* 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.
......
......@@ -11,6 +11,7 @@ const icons = {
'expand-menu': require('../../images/icons/expand-menu.svg'),
copy: require('../../images/icons/copy.svg'),
cursor: require('../../images/icons/cursor.svg'),
help: require('../../images/icons/help.svg'),
leave: require('../../images/icons/leave.svg'),
refresh: require('../../images/icons/refresh.svg'),
share: require('../../images/icons/share.svg'),
......@@ -40,7 +41,9 @@ function SvgIcon({ name, className = '' }) {
markup,
]);
return <span dangerouslySetInnerHTML={markup} ref={element} />;
return (
<span className="svg-icon" dangerouslySetInnerHTML={markup} ref={element} />
);
}
SvgIcon.propTypes = {
......
......@@ -41,6 +41,10 @@ describe('topBar', function() {
return el.querySelector('.top-bar__apply-update-btn');
}
function helpBtn(el) {
return el.querySelector('.top-bar__help-btn');
}
function createTopBar(inputs) {
const defaultInputs = {
isSidebar: true,
......@@ -79,6 +83,16 @@ describe('topBar', function() {
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() {
const onShowHelpPanel = sinon.stub();
const onLogin = sinon.stub();
......@@ -90,9 +104,6 @@ describe('topBar', function() {
});
const loginControl = el.find('login-control').controller('loginControl');
loginControl.onShowHelpPanel();
assert.called(onShowHelpPanel);
loginControl.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) {
wrapReactComponent(require('./components/timestamp'))
)
.component('topBar', require('./components/top-bar'))
.component(
'userMenu',
wrapReactComponent(require('./components/user-menu'))
)
.directive('hAutofocus', require('./directive/h-autofocus'))
.directive('hBranding', require('./directive/h-branding'))
......
<!-- New controls -->
<span class="login-text"
ng-if="vm.newStyle && vm.auth.status === 'unknown'"></span>
ng-if="vm.auth.status === 'unknown'"></span>
<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.onLogin()" h-branding="accentColor">Log in</a>
</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 -->
<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>
<user-menu auth="vm.auth" on-logout="vm.onLogout()" ng-if="vm.auth.status === 'logged-in'"/>
......@@ -11,12 +11,18 @@
always-expanded="true">
</search-input>
<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
class="login-control"
auth="vm.auth"
new-style="false"
on-show-help-panel="vm.onShowHelpPanel()"
on-login="vm.onLogin()"
on-logout="vm.onLogout()">
on-logout="vm.onLogout()"
on-sign-up="vm.onSignUp()">
</login-control>
</div>
<!-- New design for the top bar, as used in the sidebar.
......@@ -49,11 +55,15 @@
aria-label="Share this page">
<i class="h-icon-annotation-share"></i>
</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
class="login-control"
auth="vm.auth"
new-style="true"
on-show-help-panel="vm.onShowHelpPanel()"
on-login="vm.onLogin()"
on-logout="vm.onLogout()"
on-sign-up="vm.onSignUp()">
......
.login-control{
.login-control {
flex-shrink: 0;
}
......@@ -6,22 +6,3 @@
font-size: $body2-font-size;
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 @@
// Force top-bar onto a new compositor layer so that it does not judder when
// the window is scrolled.
transform: translate3d(0,0,0);
transform: translate3d(0, 0, 0);
}
.top-bar--theme-clean {
......@@ -93,6 +93,12 @@
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 {
display: inline-block;
line-height: 12px;
......
......@@ -43,6 +43,7 @@ $base-line-height: 20px;
@import './components/share-link';
@import './components/sidebar-tutorial';
@import './components/simple-search';
@import './components/svg-icon';
@import './components/spinner';
@import './components/tags-input';
@import './components/thread-list';
......@@ -51,7 +52,8 @@ $base-line-height: 20px;
// Top-level styles
// ----------------
html, body {
html,
body {
height: 100%;
}
......@@ -105,7 +107,7 @@ hypothesis-app {
border-radius: 2px;
font-family: $sans-font-family;
font-weight: 300;
margin-bottom: .72em;
margin-bottom: 0.72em;
padding: 1em;
position: relative;
background-color: $body-background;
......@@ -114,7 +116,9 @@ hypothesis-app {
border: 1px none $gray-lighter;
border-bottom-style: solid;
padding: 0 0 1.1em;
li a { padding-bottom: .231em }
li a {
padding-bottom: 0.231em;
}
}
.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